Compare commits

..

36 Commits

Author SHA1 Message Date
Giteabot 30cc41ff97 fix(web): normalize content for edit history diff and fix comment history dropdown (#39616) (#39627)
Backport #39616 by @SergioBenitez

Editing a comment as follows:

```diff
# Deployment checklist

- Timeout: 30 seconds.
+ Timeout: 60 seconds.
Restart the worker before deploying.
Check the logs after deployment.
```

Gitea would render the history this way, before, inserting spurious new
lines:

<img width="1280" height="720" alt="before"
src="https://github.com/user-attachments/assets/5391167b-ac04-4746-b544-add926230ad8"
/>

With this change, it now renders it like this:

<img width="1280" height="720" alt="after"
src="https://github.com/user-attachments/assets/369f1f19-0eaf-4da5-99e5-aeedbac6050c"
/>

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Sergio Benitez <SergioBenitez@users.noreply.github.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-06 08:57:59 +00:00
Giteabot 7f672160de ci(release): automate signed release tags and release notes (#39544) (#39622)
Backport #39544

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: bircni <bircni@users.noreply.github.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-06 08:14:58 +00:00
Giteabot e27f69bc88 fix(egress): expose more ranges as restricted rather than reserved (#39560) (#39624)
Backport #39560 by TheFox0x7

fixes: https://github.com/go-gitea/gitea/issues/39557

Signed-off-by: TheFox0x7 <thefox0x7@gmail.com>
Co-authored-by: TheFox0x7 <thefox0x7@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-06 15:24:11 +08:00
Giteabot a67171af37 fix: make "edit pr title & target branch" get correct branch (#39612) (#39621)
Backport #39612

* Fix #39610
* Regression of #39262

Also, the old code is very fragile: `#branch_target` is from translation
string, so refactored it together

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-06 06:04:30 +00:00
Giteabot c7bd37e901 fix(actions): refresh reusable caller status when children are skipped (#39589) (#39609)
Backport #39589 by @bircni

When the job emitter itself finishes the last jobs of a called workflow
(e.g. skipped by `if:`), `UpdateRunJob` cascades the caller to done in
the database only. The pass kept the caller unfinished in memory and
nothing re-emitted the run, so jobs that `needs:` the caller stayed
`Pending` and the caller's commit status stayed "In progress".

The pass now reloads such callers so their commit statuses close, and
re-emits the run so their dependents are resolved in the next pass. They
are not resolved in the same pass because their `if:` and `concurrency:`
read the caller from the database, where it is not finished until the
pass writes the children.

* Fixes https://github.com/go-gitea/gitea/issues/39587
* Fixes https://github.com/go-gitea/gitea/issues/39586

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-06 05:36:40 +00:00
wxiaoguang 0a4078558e fix: migrate broken team authorize access mode (#39579) (#39580)
backport #39579
2026-10-06 05:02:08 +00:00
silverwind e66e773a6f fix(pull): fetch PR head refs instead of pushing them (#39603) (#39607)
Backport https://github.com/go-gitea/gitea/pull/39603 by @silverwind

Uses `FetchRemoteCommit`, as v28 has no `FetchRemoteTempCommit`, so no
`--no-write-fetch-head` is involved.
2026-10-05 20:31:46 +02:00
silverwind 08c123299a revert: return 401 for unregistered runner (#39599)
Reverts the backport https://github.com/go-gitea/gitea/pull/39585 before
v28.0.1 ships. The change stays on main for v29.

gitea-runner v4.1.0 recognizes a rejected registration only by the old
`Unknown`-coded `rpc error: code = Unauthenticated desc = unregistered
runner` error. With the new `Unauthenticated` reply, an ephemeral runner
keeps its spent registration file and fails on every restart instead of
registering again. The runner fix is in
https://gitea.com/gitea/runner/pulls/1287, so a patch release shouldn't
change runner behavior before that fix has shipped.

Written by Claude Code.
2026-10-04 22:04:11 -07:00
Giteabot 219b0e7c17 fix(git): return no submodule web link when the URL cannot be parsed (#39274) (#39592)
Backport #39274

Co-authored-by: KBS <youdie006@naver.com>
2026-10-04 14:00:15 +00:00
Giteabot 02fd18970b fix(httpcache): raw files return 304 after a change when the new commit is older (#39435) (#39591)
Backport #39435

Co-authored-by: breken <support@brekfuz.com>
2026-10-04 13:39:06 +00:00
Giteabot efee6d7ce4 fix(markup): link team mentions that use a different org name case (#39436) (#39588)
Backport #39436 by @breken-ai

The `org` render meta is lower-cased (`Repository.composeCommonMetas`
sets `metas["org"] = strings.ToLower(repo.OwnerName)`), but
`mentionProcessor` compares it with the org part of the mention exactly
as typed, while the team part is lower-cased before its lookup. So in a
repository of the org `MyOrg`, `@MyOrg/developers` stays plain text,
although `ResolveIssueMentionsByVisibility` lower-cases the name and
still notifies that team.

This compares the org part case-insensitively and adds a render test for
team mentions.

The code is the same on `release/v1.27`, so it could be backported.

### Verification
- `go test ./modules/markup/ -run TestRender_TeamMention -count=1`: the
`@Org1/Developers` case renders as plain text on current main and as the
team link with this change; `go test ./modules/markup/...` passes.
- `golangci-lint run ./modules/markup/` (v2.13.2): 0 issues.

### AI assistance
This contribution was prepared with AI assistance. The behavior, the
metas and mention-resolution code paths, the patch and the test were
reviewed manually.

> Built by breken, your AI support engineer - breken.ai - this one's on
us.

Co-authored-by: breken <support@brekfuz.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-04 13:10:58 +00:00
Giteabot 0ca36eeb3c fix(actions): return 401 for unregistered runner (#39578) (#39585)
Backport #39578

Signed-off-by: Alex Mitre <mitre88@users.noreply.github.com>
Co-authored-by: Dr Alex Mitre <bedr10_capacitacion@hotmail.com>
Co-authored-by: Alex Mitre <mitre88@users.noreply.github.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-04 04:58:32 -07:00
Giteabot 3ee1924069 fix(user): restore organizations tab on user profile (#39577) (#39581)
Backport #39577

Fixes #39572

The shared user cards template calls the User-only `IsTypeBot` method.
The profile organizations tab passed `*organization.Organization` values
to that template, so `/{username}?tab=organizations` returned a 500
error.

Organizations are now converted to Users before rendering.

Co-authored-by: Zettat123 <zettat123@gmail.com>
2026-10-04 15:51:14 +08:00
Giteabot 99ac787400 fix(api): allow bots with pending password changes (#39551) (#39566)
Backport #39551 by @bircni

Allow bot accounts to use the API when a legacy password-change flag is
set, since bots cannot complete the interactive password-change flow.
Preserve password-change enforcement for human accounts and restrictions
for inactive or prohibited accounts.

Fix https://github.com/go-gitea/gitea/issues/39542

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-03 19:19:44 +00:00
Giteabot 1856fef7a8 fix(actions): restore pushes to protected branches (#39564) (#39567)
Backport #39564 by @bircni

Use the Actions token's loaded write permission when checking
protected-branch pushes. Preserve push and force-push allowlists and add
regression coverage.

Fixes: https://github.com/go-gitea/gitea/issues/39563
Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-03 18:12:17 +00:00
wxiaoguang 7440f1e452 fix: make navbar stopwatch button can show popup (#39562)
regression:
https://github.com/go-gitea/gitea/pull/36965#pullrequestreview-5400326165

partially backport the "href" fix from #39554
2026-10-03 16:58:45 +02:00
Giteabot 638c75a4d3 ci: relay fork PR reviews to giteabot through workflow_run (#39546) (#39561)
Backport #39546 by @silverwind

`pull_request_review` runs on fork PRs, which includes all backport PRs,
get a read-only token and no secrets, so giteabot cannot write lgtm
labels and statuses there. A no-op `giteabot-review` workflow now
triggers giteabot through `workflow_run`, which gets both. This allows
retiring the legacy fly.io webhook bot.

Part of https://github.com/go-gitea/giteabot/issues/15

---
This PR was written by Claude.

Co-authored-by: silverwind <me@silverwind.io>
2026-10-03 09:49:44 +00:00
Giteabot 575754cbeb fix: use READ_COMMITTED_SNAPSHOT on MSSQL (#39512) (#39558)
Backport #39512

MSSQL's default READ COMMITTED makes reads wait on writers, so the
runner pickup deadlocks with concurrent claims, flaking
`TestCreateTaskForRunnerConcurrentClaim`.

- Enable `READ_COMMITTED_SNAPSHOT` on MSSQL so it reads like PostgreSQL
and MySQL
- Read the pickup cursor before claiming, a lost claim could skip
waiting jobs
- Add tests that fail without consistent READ COMMITTED

Performance: Writes on MSSQL now also store the previous row version in
tempdb, the same versioning cost PostgreSQL and MySQL always pay, and
Azure SQL enables it by default. Reads no longer block on writers, and a
32-runner pickup stress test ran 2.5x faster with it.

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-03 08:21:46 +00:00
wxiaoguang f24f3e1eda fix: make image captcha work with csp (#39555)
fix #39553
2026-10-03 07:40:13 +00:00
Giteabot 1f615a406f fix(git): tolerate concurrent repacks in go-git storage (#39536) (#39556)
Backport #39536 by @silverwind

Since `transfer.fsckObjects` makes fetches keep a pack, git 2.54+
background maintenance repacks a mirror right after its sync fetch, and
go-git then misses objects mid-repack. Fixes these flakes:

-
https://github.com/go-gitea/gitea/actions/runs/36875305717/job/110419770196
-
https://github.com/go-gitea/gitea/actions/runs/36900439473/job/110498845882

Changes:

- Keep reindexing while the pack set changes instead of retrying once
- List packs only once their `.idx` exists and don't fail the listing on
files removed mid-repack
- Look up large objects again when their file is gone before reading

Co-authored-by: silverwind <me@silverwind.io>
2026-10-03 02:44:06 +00:00
Giteabot 9c77a87908 fix(markup): use installed math fonts for MathML in Chromium (#39491) (#39547)
Backport #39491 by @Copilot

On Linux and ChromeOS, Chromium resolves the `math` font family to Latin
Modern Math, which neither installs, so MathML renders with a text font
and brackets and large operators don't stretch, see
https://issues.chromium.org/issues/40069293. The new `--fonts-math`
variable keeps `math` first, so browsers that always resolve it keep
their font. Only Chromium falls through to the math fonts Linux and
ChromeOS install by default:

- `STIX Two Math`: Fedora, and `fonts-stix` on Ubuntu 26.04
- `DejaVu Math TeX Gyre`: Debian 13 and openSUSE
- `Noto Sans Math`: Fedora and ChromeOS, last because Debian and Ubuntu
ship an older version without a `MATH` table

Math fonts also have smaller x-heights than UI fonts, so MathML rendered
smaller than KaTeX and the surrounding text in every browser, see
https://github.com/w3c/mathml-core/issues/41. `font-size-adjust:
ex-height 0.52` scales whichever math font is used to KaTeX's x-height.
KaTeX output is unchanged.

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

![before and
after](https://github.com/user-attachments/assets/8319c9a9-363f-4d14-ad3d-3d2282bd0df9)

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: bircni <75789103+bircni@users.noreply.github.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-03 04:34:26 +08:00
Giteabot 4c3df3ab3c fix: use READ COMMITTED transactions on MySQL and MariaDB (#39506) (#39537)
Backport #39506 by @silverwind

MariaDB 11.6.2+ defaults `innodb_snapshot_isolation` to `ON`, which
fails REPEATABLE READ transactions with error 1020 when a row they write
changed after their first read. Gitea's background work like push
processing writes the same rows, so merges, issue closes and workflow
runs fail sporadically.

- Use READ COMMITTED on MySQL and MariaDB, like PostgreSQL and MSSQL
- Update xorm to v1.4.3

Replaces: https://github.com/go-gitea/gitea/pull/39494
Fixes: https://github.com/go-gitea/gitea/issues/39492

Signed-off-by: silverwind <me@silverwind.io>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-01 14:19:32 -07:00
Giteabot 565957503c fix(git): avoid unnecessary timers during language stats (#39531) (#39535)
Backport #39531 by @CalvinTjoaquinn

## What

`BatchChecker.CheckPath` uses `time.After` inside its read loop. This
replaces it with a `time.Timer` that is stopped once the attribute
arrives.

## Why

```go
for i := 0; i < c.attributesNum; i++ {
	select {
	case <-time.After(5 * time.Second):
		// there is no "hang" problem now. This code is just used to catch other potential problems.
		return nil, reportTimeout()
	case attr, ok := <-c.stdOut.ReadAttribute():
```

`time.After` has no way to be cancelled, so the timer it allocates stays
in the runtime timer heap for the full five seconds whichever case the
`select` picks. On the normal path the attribute arrives immediately and
the timer is abandoned while still pending.

The multiplier is what makes it worth changing rather than leaving as
noise. The loop runs `len(LinguistAttributes)` times, which is six, and
`CheckPath` is called once per file:

```go
// modules/git/languagestats/language_stats_get.go:95, in the loop over repository files
attrs, err := checker.CheckPath(f.Name())
// services/gitdiff/gitdiff.go:1408, in the loop over diff files
attrs, err := checker.CheckPath(diffFile.Name)
```

So a language-stats pass over a repository of N files holds up to 6N
pending five-second timers, and a large diff does the same per file. By
the comment's own account that timeout path does not fire in practice,
so every one of those timers is allocated and held for nothing.

## The change

`time.NewTimer` plus `Stop` on the paths that win, which keeps the
behaviour identical: each iteration still gets its own five-second
budget, and the timer is released as soon as the attribute or the
context arrives rather than five seconds later.

If you would rather have a single budget for the whole read, one timer
hoisted above the loop with `defer timeout.Stop()` is simpler and
stricter, since six attributes from an already running `git check-attr`
should arrive together. That changes the semantics from per-attribute to
per-call, so I left it alone and am happy to switch if you prefer it.

## Verification

```
go build ./modules/git/...
go vet ./modules/git/attribute/
go test -count=1 ./modules/git/attribute/      # 11 tests, 0 failures
golangci-lint run ./modules/git/attribute/...
gofmt -l modules/git/attribute/                # no output
```

The package's own `CheckPath` tests cover the success path, the
closed-stdout path and the context-cancelled path, which are the three
`select` arms touched here.

Found with a small AST pass over the tree looking for `time.After`
inside loop bodies. `staticcheck`'s SA1015 covers `time.Tick` and says
nothing about this shape, so no linter in the current set reports it. Of
the nine other hits in the tree the rest look deliberate or harmless,
and `modules/queue/workergroup.go` already guards against exactly this
by only creating a debounce timer when none is pending, so I only
changed this one.

<sub>Disclosure per the AI Contribution Policy: I used an AI tool to
help find this and to draft the description. The counts above are
`len(LinguistAttributes)` and the two call sites cited, so they can be
checked directly.</sub>

Signed-off-by: Calvin Tjoaquinn <calvintjoa23@gmail.com>
Co-authored-by: Calvin Tjoaquinn <66313400+CalvinTjoaquinn@users.noreply.github.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-01 19:44:48 +00:00
Giteabot e48591ba64 fix(api): add index tiebreaker to commit status ordering (#39508) (#39525)
Backport #39508 by @perfectra1n

Commit status list orders only by `created_unix`/`updated_unix`, which
have 1-second resolution while CI often posts many statuses per second.
With LIMIT/OFFSET paging, databases (e.g. PostgreSQL using a Sort plan)
may order tied rows differently per page, so `GET
/repos/{owner}/{repo}/commits/{ref}/statuses` returns some statuses
twice and never returns others.

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

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

Co-authored-by: Jon Fuller <jonfuller2012@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-01 18:03:00 +00:00
Giteabot f2a08e0261 fix(oauth2): allow users to approve scope changes (#38942) (#39521)
Backport #38942 by @Harsh-128

Lets users approve an OAuth2 scope change on an existing grant instead
of failing with `a grant exists with different scope`. Fixes
https://github.com/go-gitea/gitea/issues/38940.

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

<img width="500" alt="consent page with new scopes"
src="https://github.com/user-attachments/assets/40282353-32fe-4d87-9929-08aabbab32f1"
/>

Co-authored-by: Harsh Sharma <harshee2000@gmail.com>
Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-01 17:35:17 +00:00
Giteabot 532fb8f4f4 fix(git): reindex go-git storage when a concurrent repack removes packs (#39510) (#39534)
Backport #39510 by @silverwind

Improve the go-git workaround to fix these flakes:

- https://github.com/go-gitea/gitea/actions/runs/36721877142/job/109908823684
- https://github.com/go-gitea/gitea/actions/runs/36799665163/job/110170983591

Co-authored-by: silverwind <me@silverwind.io>
2026-10-01 16:12:04 +00:00
Giteabot 25416e9be7 fix: trace git command correctly (#39520) (#39524)
Backport #39520 by @wxiaoguang

Help  #39410

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-01 10:19:33 +00:00
Giteabot 1c4fff096f enhance(packages/npm): improve npm client compatibility (#39434) (#39522)
Backport #39434 by @silverwind

Aligns the npm registry with what npm, pnpm and yarn expect:

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

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

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-01 08:09:21 +00:00
Giteabot d16daed041 fix: handle git branch name with special chars correctly (#39483) (#39515)
Backport #39483 

Fix the bugs:
* Commit graph page doesn't show
* PR command line instructions are wrong

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-01 14:07:00 +08:00
Giteabot 2d58c8c3df fix: add missing checks to several API and web handlers (#39501) (#39507)
Backport #39501 by @silverwind

Several handlers skipped checks that their sibling routes or settings
already enforce. This brings them in line.

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

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: bircni <bircni@icloud.com>
2026-09-30 18:46:59 +00:00
Giteabot 1e28bb1bd7 fix(markup): don't escape ambiguous characters in MathML (#39493) (#39505)
Co-authored-by: Nico Schlömer <nschloe@users.noreply.github.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-09-30 16:56:23 +00:00
Giteabot f0e8c3c3d0 fix(markup): skip post-processing inside MathML (#39497) (#39502)
Co-authored-by: Nico Schlömer <nschloe@users.noreply.github.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-09-30 08:06:27 -07:00
Giteabot 7c58b73243 fix: copy new access token to clipboard (#39496) (#39499)
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-09-30 13:45:37 +00:00
Giteabot 6546382f4e fix: npm route (#39488) (#39490)
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-09-30 12:08:14 +00:00
Giteabot 0930bd71fe fix(actions): keep runs order after auto refresh (#39479) (#39481)
Co-authored-by: JerryLien <jerrylien0801@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-09-30 18:57:19 +08:00
Giteabot 15b8a5805a ci: Also release for other versions than 1 majors (#39475) (#39476) 2026-09-29 20:01:48 +00:00
131 changed files with 1481 additions and 12919 deletions
-59
View File
@@ -1,59 +0,0 @@
# The full repository name
repo: go-gitea/gitea
# Service type (gitea or github)
service: github
# Base URL for Gitea instance if using gitea service type (optional)
# Default: https://gitea.com
base-url:
# Changelog groups and which labeled PRs to add to each group
groups:
-
name: BREAKING
labels:
- pr/breaking
-
name: SECURITY
labels:
- topic/security
-
name: FEATURES
labels:
- type/feature
-
name: ENHANCEMENTS
labels:
- type/enhancement
-
name: PERFORMANCE
labels:
- performance/memory
- performance/speed
- performance/bigrepo
- performance/cpu
-
name: BUGFIXES
labels:
- type/bug
-
name: TESTING
labels:
- type/testing
-
name: BUILD
labels:
- topic/build
- topic/code-linting
-
name: DOCS
labels:
- type/docs
-
name: MISC
default: true
# regex indicating which labels to skip for the changelog
skip-labels: skip-changelog|backport\/.+
+59
View File
@@ -0,0 +1,59 @@
name: Release
description: Track a Gitea release (for release managers).
title: "Release Gitea "
body:
- type: markdown
attributes:
value: |
Follow the [release management guide](https://github.com/go-gitea/gitea/blob/main/docs/release-management.md).
Set the issue title and milestone to the version being released. Replace the examples below and mark inapplicable tasks as such.
CI signs the tag, generates release notes, and publishes binaries and containers. Track verification here; no manual changelog PR or release upload is needed.
- type: input
id: version
attributes:
label: Version
placeholder: "28.0.1"
validations:
required: true
- type: input
id: branch
attributes:
label: Release branch
placeholder: "release/v28"
validations:
required: true
- type: textarea
id: checklist
attributes:
label: Release checklist
description: Keep workflow runs, release URLs, and follow-up PRs alongside the relevant tasks.
value: |
### Preparation
- [ ] Resolve release blockers and confirm milestone issues and PRs are resolved or deferred.
- [ ] Confirm required backports are merged and release branch CI passes.
- [ ] For a new release line, create the release branch and tag its fork point on main with the next version's -dev tag.
### Release
- [ ] Run https://github.com/go-gitea/gitea/actions/workflows/release-create-tag.yml on the release branch with the selected version and obtain maintainer approval.
- [ ] Confirm https://github.com/go-gitea/gitea/actions/workflows/release-tag-version.yml succeeds for the new tag (binaries and containers).
- [ ] Verify the public GitHub release, generated notes, binary attachments, and signatures at https://github.com/go-gitea/gitea/releases.
- [ ] Verify binaries and signatures at https://dl.gitea.com/gitea/ for this version.
- [ ] Verify versioned regular and rootless images on Docker Hub and GHCR, and smoke-test the release.
### Follow-up
- [ ] Verify the automated https://dl.gitea.com/gitea/version.json update, where applicable to this release line.
- [ ] Verify automated Helm chart and Terraform provider update PRs and follow up if needed.
- [ ] Check Homebrew and Snap availability; record any outstanding packaging follow-up.
- [ ] Confirm documentation reflects the release, where applicable.
- [ ] Confirm and merge the release blog post, if planned: https://gitea.com/gitea/blog.
- [ ] Announce the release in Discord #announcements.
validations:
required: true
- type: textarea
id: notes
attributes:
label: Blockers and notes
description: Link outstanding work or release-specific checks using full URLs. Do not include undisclosed security details.
+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@4c9d4d3fd913b5c35f59dcc0b004a3d11d5b22bf # v1.0.7 - uses: go-gitea/giteabot@f48c6a15e0d384f037aea19cc05ff5e9551096b9 # v1.1.0
with: with:
github_token: ${{ secrets.GITEABOT_TOKEN }} github_token: ${{ secrets.GITEABOT_TOKEN }}
gitea_fork: giteabot/gitea gitea_fork: giteabot/gitea
+19
View File
@@ -0,0 +1,19 @@
name: giteabot-review
# Relays PR reviews to giteabot.yml through its workflow_run trigger, because review
# runs on fork PRs get no secrets and a read-only token. The job itself does nothing.
on:
pull_request_review:
types:
- submitted
- edited
- dismissed
permissions: {}
jobs:
relay:
runs-on: ubuntu-latest
steps:
- run: "true"
+9 -12
View File
@@ -20,13 +20,12 @@ on:
- closed - closed
- review_requested - review_requested
- review_request_removed - review_request_removed
# Review events keep review-derived state such as lgtm labels and status checks # Reviews arrive through giteabot-review because fork PR review runs get no secrets
# in sync after approvals, edits, or dismissals. workflow_run:
pull_request_review: workflows:
- giteabot-review
types: types:
- submitted - requested
- edited
- dismissed
# Periodic maintenance is still useful as a backstop for queue cleanup and # Periodic maintenance is still useful as a backstop for queue cleanup and
# other housekeeping, even though main pushes now trigger it promptly. # other housekeeping, even though main pushes now trigger it promptly.
schedule: schedule:
@@ -43,12 +42,12 @@ on:
permissions: {} permissions: {}
concurrency: concurrency:
group: ${{ format('{0}-{1}', github.workflow, (github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review') && format('pr-{0}', github.event.pull_request.number) || 'maintenance') }} group: ${{ format('{0}-{1}', github.workflow, github.event_name == 'pull_request_target' && format('pr-{0}', github.event.pull_request.number) || github.event_name == 'workflow_run' && format('review-{0}', github.event.workflow_run.head_sha) || 'maintenance') }}
cancel-in-progress: false cancel-in-progress: false
jobs: jobs:
giteabot: giteabot:
if: github.repository == 'go-gitea/gitea' if: github.repository == 'go-gitea/gitea' && (github.event_name != 'workflow_run' || github.event.workflow_run.event == 'pull_request_review')
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 30
permissions: permissions:
@@ -57,9 +56,7 @@ jobs:
pull-requests: write pull-requests: write
statuses: write statuses: write
steps: steps:
# pull_request_review runs without repository secrets on fork PRs, so fall - uses: go-gitea/giteabot@f48c6a15e0d384f037aea19cc05ff5e9551096b9 # v1.1.0
# back to the workflow token for the non-backport checks handled here.
- uses: go-gitea/giteabot@4c9d4d3fd913b5c35f59dcc0b004a3d11d5b22bf # v1.0.7
with: with:
github_token: ${{ secrets.GITEABOT_TOKEN || github.token }} 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 || 'labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions' }}
+32
View File
@@ -0,0 +1,32 @@
name: release-create-tag
run-name: Release v${{ inputs.version }} from ${{ github.ref_name }}
on:
workflow_dispatch:
inputs:
version:
description: Version to release, for example 28.0.1
required: true
permissions: {}
jobs:
tag:
runs-on: ubuntu-latest
environment: release-signing
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
token: ${{ secrets.RELEASE_TOKEN }}
- uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7.0.0
with:
gpg_private_key: ${{ secrets.GPGSIGN_KEY }}
passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }}
git_user_signingkey: true
git_committer_email: teabot@gitea.io
- env:
VERSION: ${{ inputs.version }}
run: |
[[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
git tag -s -m "v$VERSION" "v$VERSION"
git push origin tag "v$VERSION"
-149
View File
@@ -1,149 +0,0 @@
name: release-tag-rc
on:
push:
tags:
- "v[0-9]*-rc*"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
permissions: {}
jobs:
binary:
runs-on: namespace-profile-gitea-release-binary
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
# fetch all commits instead of only the last as some branches are long lived and could have many between versions
# fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567
- run: git fetch --unshallow --quiet --tags --force
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
check-latest: true
cache: false
- uses: ./.github/actions/node-setup
- run: make deps-frontend deps-backend
- run: make release
- name: Install Cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: import gpg key
id: import_gpg
uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7.0.0
with:
gpg_private_key: ${{ secrets.GPGSIGN_KEY }}
passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }}
- name: sign binaries
env:
GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
GPG_PASSPHRASE: ${{ secrets.GPGSIGN_PASSPHRASE }}
run: |
for f in dist/release/*; do
cosign sign-blob "$f" --bundle "$f.sigstore.json" --yes
echo "$GPG_PASSPHRASE" | gpg --pinentry-mode loopback --passphrase-fd 0 --batch --yes --detach-sign -u "$GPG_FINGERPRINT" --output "$f.asc" "$f"
done
# clean branch name to get the folder name in the object storage
- name: Get cleaned branch name
id: clean_name
env:
REF: ${{ github.ref }}
run: |
REF_NAME=$(echo "$REF" | sed -e 's/refs\/heads\///' -e 's/refs\/tags\/v//' -e 's/release\/v//')
echo "Cleaned name is ${REF_NAME}"
echo "branch=${REF_NAME}" >> "$GITHUB_OUTPUT"
- name: upload binaries to cloudflare r2
env:
AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }}
CLOUDFLARE_R2_BUCKET: ${{ secrets.CLOUDFLARE_R2_BUCKET }}
BRANCH: ${{ steps.clean_name.outputs.branch }}
run: |
aws s3 sync dist/release "s3://$CLOUDFLARE_R2_BUCKET/gitea/$BRANCH" --endpoint-url "https://$CLOUDFLARE_R2_ACCOUNT_ID.r2.cloudflarestorage.com" --no-progress
- name: Install GH CLI
uses: dev-hanz-ops/install-gh-cli-action@6089bdde54118ad7ca3d22053eb2d69387fd2779 # v0.3.0
with:
gh-cli-version: 2.39.1
- name: create github release
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
TAG: ${{ github.ref_name }}
run: |
gh release create "$TAG" --title "$TAG" --draft --notes-from-tag dist/release/*
container:
runs-on: namespace-profile-gitea-release-docker
permissions:
contents: read
packages: write # to publish to ghcr.io
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
# fetch all commits instead of only the last as some branches are long lived and could have many between versions
# fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567
- run: git fetch --unshallow --quiet --tags --force
- uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
with:
cache-image: false
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
id: meta
with:
images: |-
gitea/gitea
ghcr.io/go-gitea/gitea
flavor: |
latest=false
# 1.2.3-rc0
tags: |
type=semver,pattern={{version}}
annotations: |
org.opencontainers.image.authors="maintainers@gitea.io"
- uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
id: meta_rootless
with:
images: |-
gitea/gitea
ghcr.io/go-gitea/gitea
# each tag below will have the suffix of -rootless
flavor: |
latest=false
suffix=-rootless
# 1.2.3-rc0
tags: |
type=semver,pattern={{version}}
annotations: |
org.opencontainers.image.authors="maintainers@gitea.io"
- name: Login to Docker Hub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR using PAT
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: build regular container image
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
platforms: linux/amd64,linux/arm64,linux/riscv64
push: true
tags: ${{ steps.meta.outputs.tags }}
annotations: ${{ steps.meta.outputs.annotations }}
- name: build rootless container image
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
platforms: linux/amd64,linux/arm64,linux/riscv64
push: true
file: Dockerfile.rootless
tags: ${{ steps.meta_rootless.outputs.tags }}
annotations: ${{ steps.meta_rootless.outputs.annotations }}
+9 -3
View File
@@ -4,8 +4,7 @@ on:
push: push:
tags: tags:
- "v[0-9]*" - "v[0-9]*"
- "!v[0-9]*-rc*" - "!v[0-9]*-*"
- "!v[0-9]*-dev"
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
@@ -73,12 +72,19 @@ jobs:
uses: dev-hanz-ops/install-gh-cli-action@6089bdde54118ad7ca3d22053eb2d69387fd2779 # v0.3.0 uses: dev-hanz-ops/install-gh-cli-action@6089bdde54118ad7ca3d22053eb2d69387fd2779 # v0.3.0
with: with:
gh-cli-version: 2.39.1 gh-cli-version: 2.39.1
- id: range
run: |
previous=$(git tag --list --sort=-v:refname | grep -xE 'v[0-9]+\.[0-9]+\.[0-9]+' | grep -A1 -xF "$GITHUB_REF_NAME" | tail -1) # highest stable version below this one
echo "range=$previous..$GITHUB_SHA" >> "$GITHUB_OUTPUT"
- uses: orhun/git-cliff-action@a9a95522b26fe6403f7bb24031f21fb573d0f5ff # v4.9.1
with:
args: --tag ${{ github.ref_name }} ${{ steps.range.outputs.range }}
- name: create github release - name: create github release
env: env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
TAG: ${{ github.ref_name }} TAG: ${{ github.ref_name }}
run: | run: |
gh release create "$TAG" --title "$TAG" --notes-from-tag dist/release/* gh release create "$TAG" --title "$TAG" --notes-file git-cliff/CHANGELOG.md dist/release/*
container: container:
runs-on: namespace-profile-gitea-release-docker runs-on: namespace-profile-gitea-release-docker
File diff suppressed because it is too large Load Diff
-6614
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -169,7 +169,7 @@ In the PR title, describe the problem you are fixing, not how you are fixing it.
Use the first comment as a summary of your PR. \ Use the first comment as a summary of your PR. \
In the PR summary, you can describe exactly how you are fixing this problem. In the PR summary, you can describe exactly how you are fixing this problem.
PR titles must follow the [Conventional Commits](https://www.conventionalcommits.org/) format, because PRs are squash-merged and the PR title becomes the resulting commit message: PR titles must follow the [Conventional Commits](https://www.conventionalcommits.org/) format, because PRs are squash-merged and the PR title becomes the resulting commit message and release notes entry:
```text ```text
type(scope)!: subject type(scope)!: subject
+1 -1
View File
@@ -136,7 +136,7 @@ WEB_DIRS := web_src/js web_src/css
ESLINT_FILES := web_src/js tools *.ts tests/e2e ESLINT_FILES := web_src/js tools *.ts tests/e2e
STYLELINT_FILES := web_src/css web_src/js/components/*.vue STYLELINT_FILES := web_src/css web_src/js/components/*.vue
SPELLCHECK_FILES := $(GO_DIRS) $(WEB_DIRS) templates options/locale/locale_en-US.json .github $(filter-out CHANGELOG.md, $(wildcard *.go *.md *.yml *.yaml *.toml)) SPELLCHECK_FILES := $(GO_DIRS) $(WEB_DIRS) templates options/locale/locale_en-US.json .github $(wildcard *.go *.md *.yml *.yaml *.toml)
EDITORCONFIG_FILES := templates .github/workflows options/locale/locale_en-US.json EDITORCONFIG_FILES := templates .github/workflows options/locale/locale_en-US.json
GO_SOURCES := $(wildcard *.go) GO_SOURCES := $(wildcard *.go)
+1 -1
View File
@@ -120,7 +120,7 @@ See [app.example.ini](https://github.com/go-gitea/gitea/blob/main/custom/conf/ap
**Where can I find the security patches?** **Where can I find the security patches?**
In the [release log](https://github.com/go-gitea/gitea/releases) or the [change log](https://github.com/go-gitea/gitea/blob/main/CHANGELOG.md), search for the keyword `SECURITY` to find the security patches. Check the [release notes](https://github.com/go-gitea/gitea/releases) and [security advisories](https://github.com/go-gitea/gitea/security/advisories) for security patches.
(more FAQs are listed in [FAQ documentation](https://docs.gitea.com/help/faq)) (more FAQs are listed in [FAQ documentation](https://docs.gitea.com/help/faq))
+1 -1
View File
@@ -125,7 +125,7 @@ Gitea 的发音是 [/ɡɪ’ti:/](https://youtu.be/EM71-2uDAoY),就像 "gi-tea
**在哪里可以找到安全补丁?** **在哪里可以找到安全补丁?**
在 [发布日志](https://github.com/go-gitea/gitea/releases) 或 [变更日志](https://github.com/go-gitea/gitea/blob/main/CHANGELOG.md) 中,搜索关键词 `SECURITY` 以找到安全补丁。 在 [发布日志](https://github.com/go-gitea/gitea/releases) 中,搜索关键词 `SECURITY` 以找到安全补丁。
## 许可证 ## 许可证
+1 -1
View File
@@ -125,7 +125,7 @@ Gitea 的發音是 [/ɡɪ’ti:/](https://youtu.be/EM71-2uDAoY),就像 "gi-tea
**在哪裡可以找到安全補丁?** **在哪裡可以找到安全補丁?**
在 [發佈日誌](https://github.com/go-gitea/gitea/releases) 或 [變更日誌](https://github.com/go-gitea/gitea/blob/main/CHANGELOG.md) 中,搜索關鍵詞 `SECURITY` 以找到安全補丁。 在 [發佈日誌](https://github.com/go-gitea/gitea/releases) 中,搜索關鍵詞 `SECURITY` 以找到安全補丁。
## 許可證 ## 許可證
+10
View File
@@ -0,0 +1,10 @@
[git]
commit_parsers = [
{ message = "^(chore|ci)(\\([\\w/.-]+\\))?!?: ", skip = true },
{ message = "^feat", group = "Features" },
{ message = "^enhance", group = "Enhancements" },
{ message = "^perf", group = "Performance" },
{ message = "^fix", group = "Bug Fixes" },
{ message = "^docs", group = "Documentation" },
{ message = ".*", group = "Miscellaneous" },
]
+1 -21
View File
@@ -21,19 +21,8 @@ import (
"gitea.dev/modules/util" "gitea.dev/modules/util"
"github.com/caddyserver/certmagic" "github.com/caddyserver/certmagic"
"github.com/mholt/acmez/v3/acme"
) )
func acmeExternalAccountBinding() (acme.EAB, bool, error) {
if setting.AcmeEABKID == "" && setting.AcmeEABHMAC == "" {
return acme.EAB{}, false, nil
}
if setting.AcmeEABKID == "" || setting.AcmeEABHMAC == "" {
return acme.EAB{}, false, errors.New("both ACME_EAB_KID and ACME_EAB_HMAC must be set")
}
return acme.EAB{KeyID: setting.AcmeEABKID, MACKey: setting.AcmeEABHMAC}, true, nil
}
func getCARoot(path string) (*x509.CertPool, error) { func getCARoot(path string) (*x509.CertPool, error) {
r, err := os.ReadFile(path) r, err := os.ReadFile(path)
if err != nil { if err != nil {
@@ -77,14 +66,6 @@ func runACME(listenAddr string, m http.Handler) error {
log.Warn("Failed to parse CA Root certificate, using default CA trust: %v", err) log.Warn("Failed to parse CA Root certificate, using default CA trust: %v", err)
} }
} }
externalAccountBinding, hasExternalAccount, err := acmeExternalAccountBinding()
if err != nil {
return err
}
var externalAccount *acme.EAB
if hasExternalAccount {
externalAccount = &externalAccountBinding
}
// FIXME: this path is not right, it uses "AppWorkPath" incorrectly, and writes the data into "AppWorkPath/https" // FIXME: this path is not right, it uses "AppWorkPath" incorrectly, and writes the data into "AppWorkPath/https"
// Ideally it should migrate to AppDataPath write to "AppDataPath/https" // Ideally it should migrate to AppDataPath write to "AppDataPath/https"
// And one more thing, no idea why we should set the global default variables here // And one more thing, no idea why we should set the global default variables here
@@ -103,7 +84,6 @@ func runACME(listenAddr string, m http.Handler) error {
Email: setting.AcmeEmail, Email: setting.AcmeEmail,
Agreed: setting.AcmeTOS, Agreed: setting.AcmeTOS,
Profile: setting.AcmeProfile, Profile: setting.AcmeProfile,
ExternalAccount: externalAccount,
DisableHTTPChallenge: !enableHTTPChallenge, DisableHTTPChallenge: !enableHTTPChallenge,
DisableTLSALPNChallenge: !enableTLSALPNChallenge, DisableTLSALPNChallenge: !enableTLSALPNChallenge,
ListenHost: setting.HTTPAddr, ListenHost: setting.HTTPAddr,
@@ -120,7 +100,7 @@ func runACME(listenAddr string, m http.Handler) error {
// takes HTTPS down on restart (https://github.com/go-gitea/gitea/issues/38519). // takes HTTPS down on restart (https://github.com/go-gitea/gitea/issues/38519).
// Prefer keeping the existing cert and retrying renewals asynchronously. // Prefer keeping the existing cert and retrying renewals asynchronously.
ctx := graceful.GetManager().ShutdownContext() ctx := graceful.GetManager().ShutdownContext()
err = magic.ManageSync(ctx, []string{setting.AppDomain}) err := magic.ManageSync(ctx, []string{setting.AppDomain})
if err != nil { if err != nil {
cert, cacheErr := magic.CacheManagedCertificate(ctx, setting.AppDomain) cert, cacheErr := magic.CacheManagedCertificate(ctx, setting.AppDomain)
if cacheErr != nil || cert.Expired() { if cacheErr != nil || cert.Expired() {
-34
View File
@@ -1,34 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package cmd
import (
"testing"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"github.com/mholt/acmez/v3/acme"
"github.com/stretchr/testify/assert"
)
func TestAcmeExternalAccountBinding(t *testing.T) {
t.Cleanup(test.MockVariableValue(&setting.AcmeEABKID, ""))
t.Cleanup(test.MockVariableValue(&setting.AcmeEABHMAC, ""))
binding, configured, err := acmeExternalAccountBinding()
assert.NoError(t, err)
assert.False(t, configured)
assert.Empty(t, binding)
setting.AcmeEABKID = "kid"
_, _, err = acmeExternalAccountBinding()
assert.ErrorContains(t, err, "both ACME_EAB_KID and ACME_EAB_HMAC must be set")
setting.AcmeEABHMAC = "hmac"
binding, configured, err = acmeExternalAccountBinding()
assert.NoError(t, err)
assert.True(t, configured)
assert.Equal(t, acme.EAB{KeyID: "kid", MACKey: "hmac"}, binding)
}
+1 -1
View File
@@ -87,7 +87,7 @@ echo "Checking currently installed version..."
current=$(giteacmd --version | cut -d ' ' -f 3) current=$(giteacmd --version | cut -d ' ' -f 3)
[[ "$current" == "$giteaversion" ]] && echo "$current is already installed, stopping." && exit 0 [[ "$current" == "$giteaversion" ]] && echo "$current is already installed, stopping." && exit 0
if [[ -z "${no_confirm:-}" ]]; then if [[ -z "${no_confirm:-}" ]]; then
echo "Make sure to read the changelog first: https://github.com/go-gitea/gitea/blob/main/CHANGELOG.md" echo "Make sure to read the changelog first: https://github.com/go-gitea/gitea/releases"
echo "Are you ready to update Gitea from ${current} to ${giteaversion}? (y/N)" echo "Are you ready to update Gitea from ${current} to ${giteaversion}? (y/N)"
read -r confirm read -r confirm
[[ "$confirm" == "y" ]] || [[ "$confirm" == "Y" ]] || exit 1 [[ "$confirm" == "y" ]] || [[ "$confirm" == "Y" ]] || exit 1
+24 -25
View File
@@ -155,7 +155,7 @@
;; Username to use for the builtin SSH server. If blank, then it is the value of RUN_USER. ;; Username to use for the builtin SSH server. If blank, then it is the value of RUN_USER.
;BUILTIN_SSH_SERVER_USER = ;BUILTIN_SSH_SERVER_USER =
;; ;;
;; Domain name to be exposed in clone URL, defaults to the domain part of ROOT_URL ;; Domain name to be exposed in clone URL, defaults to DOMAIN or the domain part of ROOT_URL
;SSH_DOMAIN = ;SSH_DOMAIN =
;; ;;
;; Port number to be exposed in clone URL. ;; Port number to be exposed in clone URL.
@@ -198,7 +198,7 @@
;; For the built-in SSH server, choose the keypair to offer as the host key ;; For the built-in SSH server, choose the keypair to offer as the host key
;; The private key should be at SSH_SERVER_HOST_KEY and the public SSH_SERVER_HOST_KEY.pub ;; The private key should be at SSH_SERVER_HOST_KEY and the public SSH_SERVER_HOST_KEY.pub
;; relative paths are made absolute relative to the APP_DATA_PATH ;; relative paths are made absolute relative to the APP_DATA_PATH
;SSH_SERVER_HOST_KEYS=ssh/gitea.rsa, ssh/gitea.ed25519, ssh/gitea.ecdsa, ssh/gogs.rsa ;SSH_SERVER_HOST_KEYS=ssh/gitea.rsa, ssh/gogs.rsa
;; ;;
;; Enable SSH Authorized Key Backup when rewriting all keys, default is false ;; Enable SSH Authorized Key Backup when rewriting all keys, default is false
;SSH_AUTHORIZED_KEYS_BACKUP = false ;SSH_AUTHORIZED_KEYS_BACKUP = false
@@ -237,7 +237,7 @@
;SSH_PER_WRITE_PER_KB_TIMEOUT = 30s ;SSH_PER_WRITE_PER_KB_TIMEOUT = 30s
;; ;;
;; Indicate whether to check minimum key size with corresponding type ;; Indicate whether to check minimum key size with corresponding type
;MINIMUM_KEY_SIZE_CHECK = true ;MINIMUM_KEY_SIZE_CHECK = false
;; ;;
;; TLS Settings: Either ACME or manual ;; TLS Settings: Either ACME or manual
;; (Other common TLS configuration are found before) ;; (Other common TLS configuration are found before)
@@ -264,11 +264,6 @@
;; ACME profile to request from the CA (e.g. "shortlived" for raw-IP certificates) ;; ACME profile to request from the CA (e.g. "shortlived" for raw-IP certificates)
;ACME_PROFILE = ;ACME_PROFILE =
;; ;;
;; External account binding credentials; set both to enable EAB
;; ACME_EAB_HMAC should be a base64url-encoded MAC key
;ACME_EAB_KID =
;ACME_EAB_HMAC =
;;
;; ACME live directory (not to be confused with ACME directory URL: ACME_URL) ;; ACME live directory (not to be confused with ACME directory URL: ACME_URL)
;; (Refer to caddy's ACME manager https://github.com/caddyserver/certmagic) ;; (Refer to caddy's ACME manager https://github.com/caddyserver/certmagic)
;ACME_DIRECTORY = https ;ACME_DIRECTORY = https
@@ -541,7 +536,7 @@ INTERNAL_TOKEN =
;CONTENT_SECURITY_POLICY_GENERAL = ;CONTENT_SECURITY_POLICY_GENERAL =
;; ;;
;; Egress mode toggles between strictness of outgoing requests: ;; Egress mode toggles between strictness of outgoing requests:
;; Lax requires addresses to be allowed only if they are in private ranges, it allows all public ones ;; Lax requires non-public targets (private, loopback, link-local, CGNAT and special-use ranges) to be allowed, it allows all public ones
;; Strict requires an explicit allow of all addresses ;; Strict requires an explicit allow of all addresses
; EGRESS_MODE = lax ; EGRESS_MODE = lax
;; ;;
@@ -556,10 +551,12 @@ INTERNAL_TOKEN =
;; a bracketed set of ports and ranges, | separated: *.mydomain.com:[80|443|3000-3010] ;; a bracketed set of ports and ranges, | separated: *.mydomain.com:[80|443|3000-3010]
;; all ports: *.mydomain.com:* ;; all ports: *.mydomain.com:*
;; A portless entry covers all ports in Lax mode, only 80 and 443 in Strict mode ;; A portless entry covers all ports in Lax mode, only 80 and 443 in Strict mode
;; Port specs apply only where the list is consulted: in Lax mode that is private, loopback and CGNAT ;; Port specs apply only where the list is consulted: in Lax mode that is non-public targets alone,
;; targets alone, public targets are allowed on every port whatever the list says. In Strict mode every ;; public targets are allowed on every port whatever the list says. In Strict mode every
;; target is checked, so ports restrict public hosts too. ;; target is checked, so ports restrict public hosts too.
;; Reserved addresses like link-local and cloud metadata are denied ;; Non-public targets need an IP or built-in entry, a host name entry alone never covers them.
;; Reserved addresses (the IPv4-embedding NAT64, Teredo and 6to4 ranges, this-network, multicast and
;; broadcast) are denied whatever the list says. To reach them configure an HTTP proxy
;; This list is enforced on direct connections only. When an HTTP proxy is configured, restricting the proxied target is the proxy server's responsibility. ;; This list is enforced on direct connections only. When an HTTP proxy is configured, restricting the proxied target is the proxy server's responsibility.
;ALLOWED_HOST_LIST = ;ALLOWED_HOST_LIST =
@@ -841,7 +838,7 @@ LEVEL = Info
;EMAIL_DOMAIN_BLOCKLIST = ;EMAIL_DOMAIN_BLOCKLIST =
;; ;;
;; Disallow registration, only allow admins to create accounts. ;; Disallow registration, only allow admins to create accounts.
;DISABLE_REGISTRATION = true ;DISABLE_REGISTRATION = false
;; ;;
;; Allow registration only using gitea itself, it works only when DISABLE_REGISTRATION is false ;; Allow registration only using gitea itself, it works only when DISABLE_REGISTRATION is false
;ALLOW_ONLY_INTERNAL_REGISTRATION = false ;ALLOW_ONLY_INTERNAL_REGISTRATION = false
@@ -973,11 +970,12 @@ LEVEL = Info
;; Value for the domain part of the user's email address in the git log if user ;; Value for the domain part of the user's email address in the git log if user
;; has set KeepEmailPrivate to true. The user's email will be replaced with a ;; has set KeepEmailPrivate to true. The user's email will be replaced with a
;; concatenation of the user name in lower case, "@" and NO_REPLY_ADDRESS. Default ;; concatenation of the user name in lower case, "@" and NO_REPLY_ADDRESS. Default
;; value is "noreply." + the domain part of ROOT_URL ;; value is "noreply." + DOMAIN, where DOMAIN resolves to the value from server.DOMAIN
;NO_REPLY_ADDRESS = ;; Note: do not use the <DOMAIN> notation below
;NO_REPLY_ADDRESS = ; noreply.<DOMAIN>
;; ;;
;; Show Registration button, defaults to true only if both DISABLE_REGISTRATION and ALLOW_ONLY_EXTERNAL_REGISTRATION are false ;; Show Registration button
;SHOW_REGISTRATION_BUTTON = false ;SHOW_REGISTRATION_BUTTON = true
;; ;;
;; Show milestones dashboard page - a view of all the user's milestones ;; Show milestones dashboard page - a view of all the user's milestones
;SHOW_MILESTONES_DASHBOARD_PAGE = true ;SHOW_MILESTONES_DASHBOARD_PAGE = true
@@ -1674,13 +1672,13 @@ LEVEL = Info
;; ;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; ;;
;; General queue type, currently support: level, channel, redis, dummy ;; General queue queue type, currently support: persistable-channel, channel, level, redis, dummy
;; default to level ;; default to persistable-channel
;TYPE = level ;TYPE = persistable-channel
;; ;;
;; data-dir for storing level queues, individual queues will default to `queues/common` meaning the queue is shared. ;; data-dir for storing persistable queues and level queues, individual queues will default to `queues/common` meaning the queue is shared.
;; Relative paths will be made absolute against "APP_DATA_PATH" ;; Relative paths will be made absolute against "APP_DATA_PATH"
;DATADIR = queues/common ;DATADIR = queues/
;; ;;
;; Default queue length before a channel queue will block ;; Default queue length before a channel queue will block
;LENGTH = 100000 ;LENGTH = 100000
@@ -1688,7 +1686,7 @@ LEVEL = Info
;; Batch size to send for batched queues ;; Batch size to send for batched queues
;BATCH_LENGTH = 20 ;BATCH_LENGTH = 20
;; ;;
;; When `TYPE` is `level`, this provides a directory for the underlying leveldb ;; When `TYPE` is `persistable-channel`, this provides a directory for the underlying leveldb
;; or additional options of the form `leveldb://path/to/db?option=value&....`, and will override `DATADIR`. ;; or additional options of the form `leveldb://path/to/db?option=value&....`, and will override `DATADIR`.
;; When `TYPE` is `redis` and this is left empty, it falls back to the shared [redis] CONN_STR. ;; When `TYPE` is `redis` and this is left empty, it falls back to the shared [redis] CONN_STR.
;CONN_STR = ;CONN_STR =
@@ -1756,6 +1754,7 @@ LEVEL = Info
;ENABLE_OPENID_SIGNIN = false ;ENABLE_OPENID_SIGNIN = false
;; ;;
;; Whether to allow registering via OpenID ;; Whether to allow registering via OpenID
;; Do not include to rely on rhw DISABLE_REGISTRATION setting
;;ENABLE_OPENID_SIGNUP = false ;;ENABLE_OPENID_SIGNUP = false
;; ;;
;; Allowed URI patterns (POSIX regexp). ;; Allowed URI patterns (POSIX regexp).
@@ -2018,8 +2017,8 @@ LEVEL = Info
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; ;;
;; Either "memory", "file", "redis", "db", "mysql", "couchbase", "memcache" or "postgres" ;; Either "memory", "file", "redis", "db", "mysql", "couchbase", "memcache" or "postgres"
;; Default is "file". "db" will reuse the configuration in [database] ;; Default is "memory". "db" will reuse the configuration in [database]
;PROVIDER = file ;PROVIDER = memory
;; ;;
;; Provider config options ;; Provider config options
;; memory: doesn't have any config yet ;; memory: doesn't have any config yet
+4
View File
@@ -62,6 +62,10 @@ Operations that must roll back together should run inside `db.WithTx()` (or
Functions that participate in a transaction take a `context.Context` as their first Functions that participate in a transaction take a `context.Context` as their first
parameter so the transaction can be propagated. parameter so the transaction can be propagated.
PostgreSQL, MySQL and MSSQL (via `READ_COMMITTED_SNAPSHOT`) read the last committed
row version, so reads never wait for writers. Guard read-then-write logic with a
conditional `UPDATE` or a lock.
### XORM gotchas ### XORM gotchas
- Never call `x.Update(exemplar)` without an explicit `WHERE` clause — it updates - Never call `x.Update(exemplar)` without an explicit `WHERE` clause — it updates
+10 -26
View File
@@ -8,10 +8,9 @@ This document describes the release cycle, backports, versioning, and the releas
We backport PRs given the following circumstances: We backport PRs given the following circumstances:
1. Feature freeze is active, but `<version>-rc0` has not been released yet. Here, we backport as much as possible. <!-- TODO: Is that our definition with the new backport bot? --> 1. We backport bug- and security-fixes and small enhancements. Large changes such as refactors are not backported.
2. `rc0` has been released. Here, we only backport bug- and security-fixes, and small enhancements. Large PRs such as refactors are not backported anymore. <!-- TODO: Is that our definition with the new backport bot? --> 2. We never backport new features.
3. We never backport new features. 3. We never backport breaking changes except when
4. We never backport breaking changes except when
1. The breaking change has no effect on the vast majority of users 1. The breaking change has no effect on the vast majority of users
2. The component triggering the breaking change is marked as experimental 2. The component triggering the breaking change is marked as experimental
@@ -53,7 +52,6 @@ We use a release schedule so work, stabilization, and releases stay predictable.
### Cadence ### Cadence
- Aim for a major release about every three or four months. - Aim for a major release about every three or four months.
- Roughly two or three months of general development, then about one month of testing and polish called the **release freeze**.
- *Starting with v1.26 the release cycle will be more predictable and follow a more regular schedule.* - *Starting with v1.26 the release cycle will be more predictable and follow a more regular schedule.*
### Release schedule ### Release schedule
@@ -65,16 +63,6 @@ We will try to publish a new major version every three months:
- v1.28.0 in September 2026 - v1.28.0 in September 2026
- v1.29.0 in December 2026 - v1.29.0 in December 2026
#### How is the release handled?
- The release manager will tag the release candidate (e.g. `v1.26.0-rc0`) and publish it for testing in the **first week of the release month**.
- If there are no major issues, the release manager will check with the other maintainers and then tag the final release (e.g. `v1.26.0`) in the **one or two weeks following the release candidate**.
### Feature freeze
- Merge feature PRs before the freeze when you can.
- Feature PRs still open at the freeze move to the next milestone. Watch Discord for the freeze announcement.
- During the freeze, a **release branch** takes fixes backported from `main`. Release candidates ship for testing; the final release for that line is maintained from that branch.
### Patch releases ### Patch releases
During a cycle we may ship patch releases for an older line. For example, if the latest release is v1.2, we can still publish v1.1.1 after v1.1.0. During a cycle we may ship patch releases for an older line. For example, if the latest release is v1.2, we can still publish v1.1.1 after v1.1.0.
@@ -99,17 +87,13 @@ be reviewed by two maintainers and must pass the automatic tests.
## Releasing Gitea ## Releasing Gitea
- Let MAJOR, MINOR and PATCH be Major, Minor and Patch version numbers, PATCH should be rc1, rc2, 0, 1, ...... MAJOR.MINOR will be kept the same as milestones on github or gitea in future. Track each release using the [release issue template](https://github.com/go-gitea/gitea/issues/new?template=release.yaml).
- Before releasing, confirm all the version's milestone issues or PRs has been resolved. Then discuss the release on Discord channel #maintainers and get agreed with almost all the owners and mergers. Or you can declare the version and if nobody is against it in about several hours.
- If this is a big version first you have to create PR for changelog on branch `main` with PRs with label `changelog` and after it has been merged do following steps: - Before releasing, confirm all the version's milestone issues or PRs have been resolved. Then discuss the release on Discord channel #maintainers and get agreed with almost all the owners and mergers. Or you can declare the version and if nobody is against it in about several hours.
- Create `-dev` tag as `git tag -s -F release.notes vMAJOR.MINOR.0-dev` and push the tag as `git push origin vMAJOR.MINOR.0-dev`. - When creating a release branch, tag its fork point on `main` as the next version's `-dev` tag, e.g. `v30.0.0-dev` for `release/v29`.
- When CI has finished building tag then you have to create a new branch named `release/vMAJOR.MINOR` - In the GitHub Actions tab, open the `release-create-tag` workflow, click "Run workflow", select the release branch and enter a version such as `28.0.1`. After maintainer approval, it pushes a signed tag and CI publishes the release with generated notes.
- If it is bugfix version create PR for changelog on branch `release/vMAJOR.MINOR` and wait till it is reviewed and merged. - Optionally send a PR to the [blog repository](https://gitea.com/gitea/blog) announcing the release.
- Add a tag as `git tag -s -F release.notes vMAJOR.MINOR.PATCH`, release.notes file could be a temporary file to only include the changelog this version which you added to `CHANGELOG.md`.
- And then push the tag as `git push origin vMAJOR.MINOR.$`. CI will automatically create a release and upload all the compiled binary. (But currently it doesn't add the release notes automatically. Maybe we should fix that.)
- If needed send a frontport PR for the changelog to branch `main` and update the version in `docs/config.yaml` to refer to the new version.
- Send PR to [blog repository](https://gitea.com/gitea/blog) announcing the release.
- Verify all release assets were correctly published through CI on dl.gitea.com and GitHub releases. Once ACKed: - Verify all release assets were correctly published through CI on dl.gitea.com and GitHub releases. Once ACKed:
- bump the version of https://dl.gitea.com/gitea/version.json - verify the automated update of https://dl.gitea.com/gitea/version.json, where applicable to the release line
- merge the blog post PR - merge the blog post PR
- announce the release in discord `#announcements` - announce the release in discord `#announcements`
+2 -2
View File
@@ -68,7 +68,6 @@ require (
github.com/mattn/go-isatty v0.0.24 github.com/mattn/go-isatty v0.0.24
github.com/mattn/go-sqlite3 v1.14.52 github.com/mattn/go-sqlite3 v1.14.52
github.com/meilisearch/meilisearch-go v0.36.3 github.com/meilisearch/meilisearch-go v0.36.3
github.com/mholt/acmez/v3 v3.1.6
github.com/mholt/archives v0.1.5 github.com/mholt/archives v0.1.5
github.com/microcosm-cc/bluemonday v1.0.27 github.com/microcosm-cc/bluemonday v1.0.27
github.com/microsoft/go-mssqldb v1.11.2 github.com/microsoft/go-mssqldb v1.11.2
@@ -110,7 +109,7 @@ require (
modernc.org/sqlite v1.59.0 modernc.org/sqlite v1.59.0
mvdan.cc/xurls/v2 v2.6.0 mvdan.cc/xurls/v2 v2.6.0
xorm.io/builder v0.3.13 xorm.io/builder v0.3.13
xorm.io/xorm v1.4.1 xorm.io/xorm v1.4.3
) )
require ( require (
@@ -200,6 +199,7 @@ require (
github.com/markbates/going v1.0.3 // indirect github.com/markbates/going v1.0.3 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/mholt/acmez/v3 v3.1.6 // indirect
github.com/miekg/dns v1.1.72 // indirect github.com/miekg/dns v1.1.72 // indirect
github.com/mikelolasagasti/xz v1.0.1 // indirect github.com/mikelolasagasti/xz v1.0.1 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect github.com/minio/crc64nvme v1.1.1 // indirect
+2 -2
View File
@@ -862,5 +862,5 @@ pgregory.net/rapid v0.4.2 h1:lsi9jhvZTYvzVpeG93WWgimPRmiJQfGFRNTEZh1dtY0=
pgregory.net/rapid v0.4.2/go.mod h1:UYpPVyjFHzYBGHIxLFoupi8vwk6rXNzRY9OMvVxFIOU= pgregory.net/rapid v0.4.2/go.mod h1:UYpPVyjFHzYBGHIxLFoupi8vwk6rXNzRY9OMvVxFIOU=
xorm.io/builder v0.3.13 h1:a3jmiVVL19psGeXx8GIurTp7p0IIgqeDmwhcR6BAOAo= xorm.io/builder v0.3.13 h1:a3jmiVVL19psGeXx8GIurTp7p0IIgqeDmwhcR6BAOAo=
xorm.io/builder v0.3.13/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE= xorm.io/builder v0.3.13/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=
xorm.io/xorm v1.4.1 h1:m7QlNd0eBGb31IV4Q/ow0Du83rtdC1CiwlvJZGvYde8= xorm.io/xorm v1.4.3 h1:MwWFWzVr+/6D07qGCDhBAfABcuT0gvqY3XmTy1215BM=
xorm.io/xorm v1.4.1/go.mod h1:cs0ePc8O4a0jD78cNvD+0VFwhqotTvLQZv372QsDw7Q= xorm.io/xorm v1.4.3/go.mod h1:cs0ePc8O4a0jD78cNvD+0VFwhqotTvLQZv372QsDw7Q=
+5
View File
@@ -33,6 +33,7 @@ import (
"gitea.dev/modelmigration/v1_8" "gitea.dev/modelmigration/v1_8"
"gitea.dev/modelmigration/v1_9" "gitea.dev/modelmigration/v1_9"
"gitea.dev/modelmigration/v28" "gitea.dev/modelmigration/v28"
"gitea.dev/modelmigration/v29"
"gitea.dev/modules/git" "gitea.dev/modules/git"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
@@ -429,6 +430,10 @@ func prepareMigrationTasks() []*migration {
newMigration(353, "Add audit event table", v28.AddAuditEventTable), newMigration(353, "Add audit event table", v28.AddAuditEventTable),
newMigration(354, "Add Actions job queue indexes", v28.AddActionQueueIndexes), newMigration(354, "Add Actions job queue indexes", v28.AddActionQueueIndexes),
newMigration(355, "Add AutoMerge merged_commit_id column", v28.AddAutoMergeMergedCommitID), newMigration(355, "Add AutoMerge merged_commit_id column", v28.AddAutoMergeMergedCommitID),
// Gitea 28.0.0 ends at migration ID number 355 (database version 356)
// HERE: 2 migrations from 29 are added since they don't change database structure
newMigration(356, "Add index on action_run commit_sha", v29.AddActionRunCommitSHAIndex),
newMigration(357, "Normalize legacy team authorize values", v29.NormalizeLegacyTeamAuthorize),
} }
return preparedMigrations return preparedMigrations
} }
+14
View File
@@ -0,0 +1,14 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v29
import (
"testing"
"gitea.dev/modelmigration/migrationtest"
)
func TestMain(m *testing.M) {
migrationtest.MainTest(m)
}
+25
View File
@@ -0,0 +1,25 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v29
import (
"context"
"gitea.dev/modelmigration/base"
"xorm.io/xorm"
)
// AddActionRunCommitSHAIndex indexes the runs lookup by commit, which the API `head_sha` filter uses.
func AddActionRunCommitSHAIndex(_ context.Context, x base.EngineMigration) error {
type ActionRun struct {
CommitSHA string `xorm:"index"`
}
_, err := x.SyncWithOptions(xorm.SyncOptions{
IgnoreDropIndices: true,
IgnoreConstrains: true,
}, new(ActionRun))
return err
}
+25
View File
@@ -0,0 +1,25 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v29
import (
"context"
"gitea.dev/modelmigration/base"
)
// NormalizeLegacyTeamAuthorize sets leftover read/write authorize values to none.
// https://github.com/go-gitea/gitea/pull/34128 made non-admin teams use team_unit (authorize=none).
// authorize>=write now means blanket access on every unit; migrating legacy read/write
// to none preserves their existing team_unit-scoped access.
func NormalizeLegacyTeamAuthorize(_ context.Context, x base.EngineMigration) error {
// AccessModeNone=0, AccessModeRead=1, AccessModeWrite=2, AccessModeAdmin=3
_, err := x.Exec(`
UPDATE team SET authorize = 0
WHERE authorize > 0 AND authorize < 3
AND EXISTS (
SELECT 1 FROM team_unit WHERE team_unit.team_id = team.id
);`)
return err
}
+55
View File
@@ -0,0 +1,55 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v29
import (
"testing"
"gitea.dev/modelmigration/migrationtest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNormalizeLegacyTeamAuthorize(t *testing.T) {
type Team struct {
ID int64 `xorm:"pk"`
Authorize int
}
type TeamUnit struct {
ID int64 `xorm:"pk"`
TeamID int64 `xorm:"INDEX"`
}
x, deferrable := migrationtest.PrepareTestEnv(t, 0, new(Team), new(TeamUnit))
defer deferrable()
if x == nil || t.Failed() {
return
}
_, err := x.Insert(
&Team{ID: 1, Authorize: 4},
&Team{ID: 2, Authorize: 3},
&Team{ID: 3, Authorize: 2},
&Team{ID: 4, Authorize: 1},
&Team{ID: 5, Authorize: 0},
&TeamUnit{TeamID: 3},
)
require.NoError(t, err)
require.NoError(t, NormalizeLegacyTeamAuthorize(t.Context(), x))
get := func(id int64) int {
tBean := &Team{ID: id}
has, err := x.Get(tBean)
require.NoError(t, err)
require.True(t, has)
return tBean.Authorize
}
assert.Equal(t, 4, get(1))
assert.Equal(t, 3, get(2))
assert.Equal(t, 0, get(3)) // has team unit, reset to none
assert.Equal(t, 1, get(4)) // no team unit, kept
assert.Equal(t, 0, get(5))
}
+3 -3
View File
@@ -39,9 +39,9 @@ type ActionRun struct {
TriggerUserID int64 `xorm:"index"` TriggerUserID int64 `xorm:"index"`
TriggerUser *user_model.User `xorm:"-"` TriggerUser *user_model.User `xorm:"-"`
ScheduleID int64 ScheduleID int64
Ref string `xorm:"index"` // the commit/tag/… that caused the run Ref string `xorm:"index"` // the commit/tag/… that caused the run
IsRefDeleted bool `xorm:"-"` IsRefDeleted bool `xorm:"-"`
CommitSHA string CommitSHA string `xorm:"index"`
IsForkPullRequest bool // If this is triggered by a PR from a forked repository or an untrusted user, we need to check if it is approved and limit permissions when running the workflow. IsForkPullRequest bool // If this is triggered by a PR from a forked repository or an untrusted user, we need to check if it is approved and limit permissions when running the workflow.
NeedApproval bool // may need approval if it's a fork pull request NeedApproval bool // may need approval if it's a fork pull request
ApprovedBy int64 `xorm:"index"` // who approved ApprovedBy int64 `xorm:"index"` // who approved
+7 -4
View File
@@ -298,6 +298,12 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask
if err := e.Where(cond).Asc("updated", "id").Limit(pickTaskBatchSize).Find(&jobs); err != nil { if err := e.Where(cond).Asc("updated", "id").Limit(pickTaskBatchSize).Find(&jobs); err != nil {
return nil, false, err return nil, false, err
} }
// A short page means no waiting jobs remain beyond it.
isLastPage := len(jobs) < pickTaskBatchSize
if !isLastPage {
last := jobs[len(jobs)-1] // read before a lost claim bumps Updated
cursorUpdated, cursorID = last.Updated, last.ID
}
for _, v := range jobs { for _, v := range jobs {
if !runner.CanMatchLabels(v.RunsOn) { if !runner.CanMatchLabels(v.RunsOn) {
@@ -313,12 +319,9 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask
// Another runner claimed this job concurrently; try the next one. // Another runner claimed this job concurrently; try the next one.
} }
// A short page means no waiting jobs remain beyond it. if isLastPage {
if len(jobs) < pickTaskBatchSize {
return nil, false, nil return nil, false, nil
} }
last := jobs[len(jobs)-1]
cursorUpdated, cursorID = last.Updated, last.ID
} }
} }
+6
View File
@@ -564,6 +564,12 @@ func (grant *OAuth2Grant) SetNonce(ctx context.Context, nonce string) error {
return nil return nil
} }
func UpdateGrantScope(ctx context.Context, grant *OAuth2Grant, newScope string) error {
grant.Scope = newScope
_, err := db.GetEngine(ctx).ID(grant.ID).Cols("scope").Update(grant)
return err
}
// GetOAuth2GrantByID returns the grant with the given ID // GetOAuth2GrantByID returns the grant with the given ID
func GetOAuth2GrantByID(ctx context.Context, id int64) (grant *OAuth2Grant, err error) { func GetOAuth2GrantByID(ctx context.Context, id int64) (grant *OAuth2Grant, err error) {
grant = new(OAuth2Grant) grant = new(OAuth2Grant)
+20
View File
@@ -5,7 +5,9 @@ package db
import ( import (
"context" "context"
"database/sql"
"fmt" "fmt"
"time"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
@@ -59,6 +61,11 @@ func InitEngine(ctx context.Context) error {
xe.SetMaxIdleConns(setting.Database.MaxIdleConns) xe.SetMaxIdleConns(setting.Database.MaxIdleConns)
xe.SetConnMaxLifetime(setting.Database.ConnMaxLifetime) xe.SetConnMaxLifetime(setting.Database.ConnMaxLifetime)
if setting.Database.Type.IsMySQL() {
// like PostgreSQL and MSSQL, avoids MariaDB snapshot isolation errors
xe.SetDefaultTxOptions(&sql.TxOptions{Isolation: sql.LevelReadCommitted})
}
if setting.Database.SlowQueryThreshold > 0 { if setting.Database.SlowQueryThreshold > 0 {
xe.AddHook(&EngineHook{ xe.AddHook(&EngineHook{
Threshold: setting.Database.SlowQueryThreshold, Threshold: setting.Database.SlowQueryThreshold,
@@ -103,6 +110,10 @@ func InitEngineWithMigration(ctx context.Context, migrateFunc func(context.Conte
preprocessDatabaseCollation(xormEngine) preprocessDatabaseCollation(xormEngine)
if setting.Database.Type.IsMSSQL() {
enableMSSQLReadCommittedSnapshot(ctx, xormEngine)
}
// We have to run migrateFunc here in case the user is re-running installation on a previously created DB. // We have to run migrateFunc here in case the user is re-running installation on a previously created DB.
// If we do not then table schemas will be changed and there will be conflicts when the migrations run properly. // If we do not then table schemas will be changed and there will be conflicts when the migrations run properly.
// //
@@ -125,3 +136,12 @@ func InitEngineWithMigration(ctx context.Context, migrateFunc func(context.Conte
return nil return nil
} }
// enableMSSQLReadCommittedSnapshot stops MSSQL reads waiting on writers, like PostgreSQL and MySQL
func enableMSSQLReadCommittedSnapshot(ctx context.Context, engine EngineMigration) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second) // ALTER waits for all other connections to close
defer cancel()
if _, err := engine.Context(ctx).Exec("IF (SELECT is_read_committed_snapshot_on FROM sys.databases WHERE database_id = DB_ID()) = 0 ALTER DATABASE CURRENT SET READ_COMMITTED_SNAPSHOT ON"); err != nil {
log.Error("Unable to set READ_COMMITTED_SNAPSHOT=ON: %v", err)
}
}
+4 -4
View File
@@ -311,17 +311,17 @@ func (opts *CommitStatusOptions) ToConds() builder.Cond {
func (opts *CommitStatusOptions) ToOrders() string { func (opts *CommitStatusOptions) ToOrders() string {
switch opts.SortType { switch opts.SortType {
case "oldest": case "oldest":
return "created_unix ASC" return "created_unix ASC, `index` ASC"
case "recentupdate": case "recentupdate":
return "updated_unix DESC" return "updated_unix DESC, `index` DESC"
case "leastupdate": case "leastupdate":
return "updated_unix ASC" return "updated_unix ASC, `index` ASC"
case "leastindex": case "leastindex":
return "`index` DESC" return "`index` DESC"
case "highestindex": case "highestindex":
return "`index` ASC" return "`index` ASC"
default: default:
return "created_unix DESC" return "created_unix DESC, `index` DESC" // timestamps have 1s resolution, `index` keeps paging stable
} }
} }
+7 -20
View File
@@ -32,28 +32,15 @@ func TestGetCommitStatuses(t *testing.T) {
}) })
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, 5, int(maxResults)) assert.Equal(t, 5, int(maxResults))
assert.Len(t, statuses, 5) var indexes []int64
for _, status := range statuses {
assert.Equal(t, "ci/awesomeness", statuses[0].Context) indexes = append(indexes, status.Index)
assert.Equal(t, commitstatus.CommitStatusPending, statuses[0].State) }
assert.Equal(t, []int64{5, 4, 3, 2, 1}, indexes)
assert.Equal(t, "deploy/awesomeness", statuses[0].Context)
assert.Equal(t, commitstatus.CommitStatusError, statuses[0].State)
assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[0].APIURL(t.Context())) assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[0].APIURL(t.Context()))
assert.Equal(t, "cov/awesomeness", statuses[1].Context)
assert.Equal(t, commitstatus.CommitStatusWarning, statuses[1].State)
assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[1].APIURL(t.Context()))
assert.Equal(t, "cov/awesomeness", statuses[2].Context)
assert.Equal(t, commitstatus.CommitStatusSuccess, statuses[2].State)
assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[2].APIURL(t.Context()))
assert.Equal(t, "ci/awesomeness", statuses[3].Context)
assert.Equal(t, commitstatus.CommitStatusFailure, statuses[3].State)
assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[3].APIURL(t.Context()))
assert.Equal(t, "deploy/awesomeness", statuses[4].Context)
assert.Equal(t, commitstatus.CommitStatusError, statuses[4].State)
assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[4].APIURL(t.Context()))
statuses, maxResults, err = db.FindAndCount[git_model.CommitStatus](t.Context(), &git_model.CommitStatusOptions{ statuses, maxResults, err = db.FindAndCount[git_model.CommitStatus](t.Context(), &git_model.CommitStatusOptions{
ListOptions: db.ListOptions{Page: 2, PageSize: 50}, ListOptions: db.ListOptions{Page: 2, PageSize: 50},
RepoID: repo1.ID, RepoID: repo1.ID,
+6 -16
View File
@@ -123,23 +123,13 @@ func (protectBranch *ProtectedBranch) LoadRepo(ctx context.Context) (err error)
} }
// CanUserPush returns if some user could push to this protected branch // CanUserPush returns if some user could push to this protected branch
func (protectBranch *ProtectedBranch) CanUserPush(ctx context.Context, user *user_model.User) bool { func (protectBranch *ProtectedBranch) CanUserPush(ctx context.Context, user *user_model.User, permissionInRepo access_model.Permission) bool {
if !protectBranch.CanPush { if !protectBranch.CanPush {
return false return false
} }
if !protectBranch.EnableWhitelist { if !protectBranch.EnableWhitelist {
if err := protectBranch.LoadRepo(ctx); err != nil { return permissionInRepo.CanWrite(unit.TypeCode)
log.Error("LoadRepo: %v", err)
return false
}
writeAccess, err := access_model.HasAccessUnit(ctx, user, protectBranch.Repo, unit.TypeCode, perm.AccessModeWrite)
if err != nil {
log.Error("HasAccessUnit: %v", err)
return false
}
return writeAccess
} }
if slices.Contains(protectBranch.WhitelistUserIDs, user.ID) { if slices.Contains(protectBranch.WhitelistUserIDs, user.ID) {
@@ -160,17 +150,17 @@ func (protectBranch *ProtectedBranch) CanUserPush(ctx context.Context, user *use
// CanUserForcePush returns if some user could force push to this protected branch // CanUserForcePush returns if some user could force push to this protected branch
// Since force-push extends normal push, we also check if user has regular push access // Since force-push extends normal push, we also check if user has regular push access
func (protectBranch *ProtectedBranch) CanUserForcePush(ctx context.Context, user *user_model.User) bool { func (protectBranch *ProtectedBranch) CanUserForcePush(ctx context.Context, user *user_model.User, permissionInRepo access_model.Permission) bool {
if !protectBranch.CanForcePush { if !protectBranch.CanForcePush {
return false return false
} }
if !protectBranch.EnableForcePushAllowlist { if !protectBranch.EnableForcePushAllowlist {
return protectBranch.CanUserPush(ctx, user) return protectBranch.CanUserPush(ctx, user, permissionInRepo)
} }
if slices.Contains(protectBranch.ForcePushAllowlistUserIDs, user.ID) { if slices.Contains(protectBranch.ForcePushAllowlistUserIDs, user.ID) {
return protectBranch.CanUserPush(ctx, user) return protectBranch.CanUserPush(ctx, user, permissionInRepo)
} }
if len(protectBranch.ForcePushAllowlistTeamIDs) == 0 { if len(protectBranch.ForcePushAllowlistTeamIDs) == 0 {
@@ -182,7 +172,7 @@ func (protectBranch *ProtectedBranch) CanUserForcePush(ctx context.Context, user
log.Error("IsUserInTeams: %v", err) log.Error("IsUserInTeams: %v", err)
return false return false
} }
return in && protectBranch.CanUserPush(ctx, user) return in && protectBranch.CanUserPush(ctx, user, permissionInRepo)
} }
// IsUserMergeWhitelisted checks if some user is whitelisted to merge to this branch // IsUserMergeWhitelisted checks if some user is whitelisted to merge to this branch
+15
View File
@@ -407,6 +407,21 @@ func (pr *PullRequest) GetGitHeadRefName() string { // TODO: make it return RefN
return git.RefNameFromPullIndex(pr.Index).String() return git.RefNameFromPullIndex(pr.Index).String()
} }
func (pr *PullRequest) GetInstructionsCliArgs() (ret struct {
BaseBranchArg string
HeadBranchArg string
LocalBranchArg string
},
) {
ret.BaseBranchArg = util.ShellEscape(pr.BaseBranch)
ret.HeadBranchArg = util.ShellEscape(pr.HeadBranch)
ret.LocalBranchArg = ret.HeadBranchArg
if pr.HeadRepo != nil && pr.HeadRepoID != pr.BaseRepoID {
ret.LocalBranchArg = util.ShellEscape(pr.HeadRepo.OwnerName) + "-" + ret.HeadBranchArg
}
return ret
}
// GetReviewCommentsCount returns the number of review comments made on the diff of a PR review (not including comments on commits or issues in a PR) // GetReviewCommentsCount returns the number of review comments made on the diff of a PR review (not including comments on commits or issues in a PR)
func (pr *PullRequest) GetReviewCommentsCount(ctx context.Context) int { func (pr *PullRequest) GetReviewCommentsCount(ctx context.Context) int {
opts := FindCommentsOptions{ opts := FindCommentsOptions{
+4 -4
View File
@@ -40,8 +40,8 @@ type SearchUserOptions struct {
Keyword string Keyword string
Types []UserType Types []UserType
UID int64 UID int64
LoginName string // this option should be used only for admin user LoginName string // this option should be used only for admin user
SourceID optional.Option[int64] // this option should be used only for admin user, Some(0) means local users SourceID int64 // this option should be used only for admin user
OrderBy db.SearchOrderBy OrderBy db.SearchOrderBy
Visible []structs.VisibleType Visible []structs.VisibleType
Actor *User // The user doing the search Actor *User // The user doing the search
@@ -106,8 +106,8 @@ func (opts *SearchUserOptions) toSearchQueryBase(ctx context.Context) db.Session
cond = cond.And(builder.Eq{"id": opts.UID}) cond = cond.And(builder.Eq{"id": opts.UID})
} }
if opts.SourceID.Has() { if opts.SourceID > 0 {
cond = cond.And(builder.Eq{"login_source": opts.SourceID.Value()}) cond = cond.And(builder.Eq{"login_source": opts.SourceID})
} }
if opts.LoginName != "" { if opts.LoginName != "" {
cond = cond.And(builder.Eq{"login_name": opts.LoginName}) cond = cond.And(builder.Eq{"login_name": opts.LoginName})
+2
View File
@@ -98,6 +98,7 @@ func NewWebhookPolicy() *policy.Policy {
} }
p = policy.NewPolicy("webhook", policyMode(setting.Security.EgressMode), p = policy.NewPolicy("webhook", policyMode(setting.Security.EgressMode),
policy.WithAllow(setting.Webhook.AllowedHostList, "security.ALLOWED_HOST_LIST"), policy.WithAllow(setting.Webhook.AllowedHostList, "security.ALLOWED_HOST_LIST"),
policy.WithLocalNeedsIPAllow(),
policy.WithProxy(selectProxy)) policy.WithProxy(selectProxy))
return p return p
@@ -106,6 +107,7 @@ func NewWebhookPolicy() *policy.Policy {
func NewSecurityPolicy(usage string) *policy.Policy { func NewSecurityPolicy(usage string) *policy.Policy {
return policy.NewPolicy(usage, policyMode(setting.Security.EgressMode), return policy.NewPolicy(usage, policyMode(setting.Security.EgressMode),
policy.WithAllow(setting.Security.AllowedHostList, "security.ALLOWED_HOST_LIST"), policy.WithAllow(setting.Security.AllowedHostList, "security.ALLOWED_HOST_LIST"),
policy.WithLocalNeedsIPAllow(),
policy.WithProxy(proxy.Proxy())) policy.WithProxy(proxy.Proxy()))
} }
+24
View File
@@ -4,10 +4,13 @@
package egress package egress
import ( import (
"net"
"net/http" "net/http"
"net/url" "net/url"
"strconv"
"testing" "testing"
"gitea.dev/modules/egress/policy"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
"gitea.dev/modules/test" "gitea.dev/modules/test"
@@ -70,6 +73,27 @@ func TestWebhookPolicyProxy(t *testing.T) {
} }
} }
func TestWebhookPolicyNeedsIPAllow(t *testing.T) {
defer test.MockVariableValue(&setting.Webhook.AllowedHostList, "localhost")()
defer test.MockVariableValue(&setting.Security.EgressMode, "lax")()
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() { _ = ln.Close() })
dial := func() error {
tcpAddr, ok := ln.Addr().(*net.TCPAddr)
require.True(t, ok)
target := net.JoinHostPort("localhost", strconv.Itoa(tcpAddr.Port))
conn, err := NewWebhookPolicy().NewDialContext()(t.Context(), "tcp", target)
if err == nil {
_ = conn.Close()
}
return err
}
assert.ErrorIs(t, dial(), policy.ErrDenied) // a host name entry doesn't cover the loopback address
setting.Webhook.AllowedHostList = "loopback"
assert.NoError(t, dial()) // an IP entry does
}
func TestSecurityPolicy(t *testing.T) { func TestSecurityPolicy(t *testing.T) {
defer test.MockVariableValue(&setting.Security.AllowedHostList, "avatars.example.com")() defer test.MockVariableValue(&setting.Security.AllowedHostList, "avatars.example.com")()
defer test.MockVariableValue(&setting.Security.EgressMode, "lax")() defer test.MockVariableValue(&setting.Security.EgressMode, "lax")()
+43 -33
View File
@@ -411,37 +411,43 @@ var cgnatRange = netip.MustParsePrefix("100.64.0.0/10") // RFC 6598
// reservedRanges are never dialable, based on https://microsoft.github.io/AntiSSRF/ipaddressranges.html // reservedRanges are never dialable, based on https://microsoft.github.io/AntiSSRF/ipaddressranges.html
var reservedRanges = func() (ranges []netip.Prefix) { var reservedRanges = func() (ranges []netip.Prefix) {
for _, cidr := range []string{ for _, cidr := range []string{
"0.0.0.0/8", // "this network" "0.0.0.0/8", // "this network"
"100.100.100.200/32", // Alibaba Cloud metadata "168.63.129.16/32", // Azure WireServer
"168.63.129.16/32", // Azure WireServer "192.88.99.0/24", // 6to4 relay anycast
"169.254.0.0/16", // link-local, cloud metadata endpoints "224.0.0.0/4", // multicast
"192.0.0.0/24", // IETF protocol assignments "240.0.0.0/4", // reserved, incl. limited broadcast
"192.0.2.0/24", // TEST-NET-1 "::/96", // IPv4-compatible, embeds IPv4
"192.31.196.0/24", // AS112 "::ffff:0:0:0/96", // IPv4-translated, embeds IPv4
"192.52.193.0/24", // AMT "64:ff9b::/96", // wkp NAT64
"192.88.99.0/24", // 6to4 relay anycast "64:ff9b:1::/48", // local-use NAT64
"192.175.48.0/24", // AS112 "2001::/32", // Teredo, embeds IPv4
"198.18.0.0/15", // benchmarking "2002::/16", // 6to4, embeds IPv4
"198.51.100.0/24", // TEST-NET-2 "ff00::/8", // multicast
"203.0.113.0/24", // TEST-NET-3 } {
"224.0.0.0/4", // multicast ranges = append(ranges, netip.MustParsePrefix(cidr))
"240.0.0.0/4", // reserved, incl. limited broadcast }
"::/96", // IPv4-compatible, embeds IPv4 return ranges
"::ffff:0:0:0/96", // IPv4-translated, embeds IPv4 }()
"64:ff9b::/96", // wkp NAT64
"64:ff9b:1::/48", // local-use NAT64 // restrictedRanges are dialable if they have been explicitly allowed.
"100::/64", // discard-only var restrictedRanges = func() (ranges []netip.Prefix) {
"100:0:0:1::/64", // dummy for _, cidr := range []string{
"2001::/23", // IETF protocol assignments, incl. Teredo and ORCHID "192.0.0.0/24", // IETF protocol assignments
"2001:db8::/32", // documentation "192.0.2.0/24", // TEST-NET-1
"2002::/16", // 6to4, embeds IPv4 "192.31.196.0/24", // AS112
"2620:4f:8000::/48", // AS112 "192.52.193.0/24", // AMT
"3fff::/20", // documentation "192.175.48.0/24", // AS112
"5f00::/16", // SRv6 SIDs "198.18.0.0/15", // benchmarking
"fd00:ec2::254/128", // AWS IMDS "198.51.100.0/24", // TEST-NET-2
"fe80::/10", // link-local "203.0.113.0/24", // TEST-NET-3
"fec0::/10", // site-local "100::/64", // discard-only
"ff00::/8", // multicast "100:0:0:1::/64", // dummy
"2001::/23", // IETF protocol assignments
"2001:db8::/32", // documentation
"2620:4f:8000::/48", // AS112
"3fff::/20", // documentation
"5f00::/16", // SRv6 SIDs
"fec0::/10", // site-local
} { } {
ranges = append(ranges, netip.MustParsePrefix(cidr)) ranges = append(ranges, netip.MustParsePrefix(cidr))
} }
@@ -451,10 +457,14 @@ var reservedRanges = func() (ranges []netip.Prefix) {
// classifyAddr reports the class of a canonical address. // classifyAddr reports the class of a canonical address.
func classifyAddr(ip netip.Addr) addrClass { func classifyAddr(ip netip.Addr) addrClass {
switch { switch {
case ip.Zone() != "" || !ip.IsLoopback() && slices.ContainsFunc(reservedRanges, func(p netip.Prefix) bool { return p.Contains(ip) }): case ip.Zone() != "" || !ip.IsLoopback() && inRange(reservedRanges, ip):
return classReserved return classReserved
case ip.IsPrivate() || ip.IsLoopback() || cgnatRange.Contains(ip): case ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || cgnatRange.Contains(ip) || inRange(restrictedRanges, ip):
return classRestricted return classRestricted
} }
return classPublic return classPublic
} }
func inRange(p []netip.Prefix, ip netip.Addr) bool {
return slices.ContainsFunc(p, func(p netip.Prefix) bool { return p.Contains(ip) })
}
+3 -3
View File
@@ -56,7 +56,7 @@ func WithBlock(hostList, key string) Option {
} }
} }
// WithLocalNeedsIPAllow requires private, loopback and CGNAT targets to match an IP allow entry (CIDR or named range), a host name match is not enough. // WithLocalNeedsIPAllow requires non-public targets (private, loopback, link-local, CGNAT and special-use ranges) to match an IP allow entry (CIDR or named range), a host name match is not enough.
func WithLocalNeedsIPAllow() Option { func WithLocalNeedsIPAllow() Option {
return func(p *Policy) { return func(p *Policy) {
p.localNeedsIPAllow = true p.localNeedsIPAllow = true
@@ -140,9 +140,9 @@ func (p *Policy) allowCheck(host string, ip netip.AddrPort, class addrClass) err
return p.notAllowedError(denyTarget(host, ip)) return p.notAllowedError(denyTarget(host, ip))
} }
if !hostnameOk { if !hostnameOk {
return fmt.Errorf("%s needs an explicit IP allow entry (private/loopback/CGNAT)", denyTarget(host, ip)) return fmt.Errorf("%s needs an explicit IP allow entry (non-public address)", denyTarget(host, ip))
} }
return fmt.Errorf("%s needs an explicit allow entry (private/loopback/CGNAT)", denyTarget(host, ip)) return fmt.Errorf("%s needs an explicit allow entry (non-public address)", denyTarget(host, ip))
} }
func (p *Policy) blockReason(host string, ip netip.AddrPort) error { func (p *Policy) blockReason(host string, ip netip.AddrPort) error {
+15 -5
View File
@@ -33,8 +33,16 @@ func TestCheckAddr(t *testing.T) {
{name: "allow host", allow: "example.com", host: "example.com", ip: "8.8.8.8", want: true}, {name: "allow host", allow: "example.com", host: "example.com", ip: "8.8.8.8", want: true},
{name: "allow cidr", allow: "10.0.0.0/8", ip: "10.0.0.5", want: true}, {name: "allow cidr", allow: "10.0.0.0/8", ip: "10.0.0.5", want: true},
{name: "block overrides allow", allow: "10.0.0.0/8", block: "10.0.0.5/32", ip: "10.0.0.5"}, {name: "block overrides allow", allow: "10.0.0.0/8", block: "10.0.0.5/32", ip: "10.0.0.5"},
{name: "reserved denied by cidr", allow: "169.254.0.0/16", ip: "169.254.169.254"}, {name: "non cloud link-local is default denied", ip: "::ffff:169.254.1.2"},
{name: "reserved denied ipv4-mapped", allow: "169.254.0.0/16", ip: "::ffff:169.254.169.254"}, {name: "link-local allowed by cidr", allow: "169.254.0.0/16", ip: "169.254.169.254", want: true},
{name: "link-local allowed ipv4-mapped", allow: "169.254.0.0/16", ip: "::ffff:169.254.169.254", want: true},
{name: "restricted range allowed by cidr", allow: "192.0.2.0/24", ip: "192.0.2.1", want: true},
{name: "ula metadata allowed by private", allow: "private", ip: "fd00:ec2::254", want: true},
{name: "reserved denied despite allow", allow: "168.63.129.16/32", ip: "168.63.129.16"},
{name: "reserved denied ipv4-mapped", allow: "168.63.129.16/32", ip: "::ffff:168.63.129.16"},
{name: "nat64 reserved denied despite allow", allow: "64:ff9b::/96", ip: "64:ff9b::a9fe:a9fe"},
{name: "teredo reserved denied despite allow", allow: "2001::/23", ip: "2001::1"},
{name: "protocol assignment allowed by cidr", allow: "2001::/23", ip: "2001:3::1", want: true},
{name: "local gate ignores host", allow: "example.com", host: "example.com", ip: "10.0.0.5", localNeedsIPAllow: true}, {name: "local gate ignores host", allow: "example.com", host: "example.com", ip: "10.0.0.5", localNeedsIPAllow: true},
{name: "local gate accepts builtin", allow: "private", ip: "100.64.0.1", localNeedsIPAllow: true, want: true}, {name: "local gate accepts builtin", allow: "private", ip: "100.64.0.1", localNeedsIPAllow: true, want: true},
{name: "local gate accepts cidr", allow: "10.0.0.0/24", ip: "10.0.0.5", localNeedsIPAllow: true, want: true}, {name: "local gate accepts cidr", allow: "10.0.0.0/24", ip: "10.0.0.5", localNeedsIPAllow: true, want: true},
@@ -46,7 +54,8 @@ func TestCheckAddr(t *testing.T) {
{name: "strict rejects unmatched host", allow: "example.com", host: "other.com", ip: "8.8.8.8", strict: true}, {name: "strict rejects unmatched host", allow: "example.com", host: "other.com", ip: "8.8.8.8", strict: true},
{name: "strict allows matched host", allow: "example.com", host: "example.com", ip: "8.8.8.8", strict: true, want: true}, {name: "strict allows matched host", allow: "example.com", host: "example.com", ip: "8.8.8.8", strict: true, want: true},
{name: "strict block overrides allow", allow: "10.0.0.0/8", block: "10.0.0.5/32", ip: "10.0.0.5", strict: true}, {name: "strict block overrides allow", allow: "10.0.0.0/8", block: "10.0.0.5/32", ip: "10.0.0.5", strict: true},
{name: "strict reserved denied by cidr", allow: "169.254.0.0/16", ip: "169.254.169.254", strict: true}, {name: "strict reserved denied despite allow", allow: "168.63.129.16/32", ip: "168.63.129.16", strict: true},
{name: "strict link-local allowed by cidr", allow: "169.254.0.0/16", ip: "169.254.169.254", strict: true, want: true},
{name: "strict local gate ignores host", allow: "example.com", host: "example.com", ip: "10.0.0.5", localNeedsIPAllow: true, strict: true}, {name: "strict local gate ignores host", allow: "example.com", host: "example.com", ip: "10.0.0.5", localNeedsIPAllow: true, strict: true},
{name: "strict local gate accepts builtin", allow: "private", ip: "100.64.0.1", localNeedsIPAllow: true, strict: true, want: true}, {name: "strict local gate accepts builtin", allow: "private", ip: "100.64.0.1", localNeedsIPAllow: true, strict: true, want: true},
} { } {
@@ -63,7 +72,7 @@ func TestCheckAddr(t *testing.T) {
mode = Strict mode = Strict
} }
err := NewPolicy("test", mode, opts...).checkAddr(tc.host, netip.AddrPortFrom(addr, 80)) err := NewPolicy("test", mode, opts...).checkAddr(tc.host, netip.AddrPortFrom(addr, 80))
assert.Equal(t, tc.want, err == nil, "%s: %v", tc.name, err) assert.Equal(t, tc.want, err == nil, "%s (%s): %v", tc.name, tc.ip, err)
} }
} }
@@ -115,8 +124,9 @@ func TestCheckHostIPs(t *testing.T) {
builtins := NewPolicy("test", Lax, WithAllow("private, loopback", "")) builtins := NewPolicy("test", Lax, WithAllow("private, loopback", ""))
assert.NoError(t, builtins.checkHostIPs(hostURL(t, "http://example.com"), ips("8.8.8.8", "100.64.0.1", "::1"))) assert.NoError(t, builtins.checkHostIPs(hostURL(t, "http://example.com"), ips("8.8.8.8", "100.64.0.1", "::1")))
assert.NoError(t, builtins.checkHostIPs(hostURL(t, "http://example.com"), ips("100.100.100.200"))) // cloud metadata is opt-in with its containing range
for _, ip := range []string{ for _, ip := range []string{
"0.1.2.3", "100.100.100.200", "168.63.129.16", "169.254.169.254", "192.0.2.1", "192.88.99.1", "198.18.0.1", "0.1.2.3", "168.63.129.16", "169.254.169.254", "192.0.2.1", "192.88.99.1", "198.18.0.1",
"198.51.100.1", "203.0.113.1", "::7f00:1", "::ffff:0:a00:5", "64:ff9b::a9fe:a9fe", "64:ff9b::808:808", "2001::1", "2001:db8::1", "198.51.100.1", "203.0.113.1", "::7f00:1", "::ffff:0:a00:5", "64:ff9b::a9fe:a9fe", "64:ff9b::808:808", "2001::1", "2001:db8::1",
"2002::1", "fe80::1", "2002::1", "fe80::1",
} { } {
+8 -2
View File
@@ -13,6 +13,7 @@ import (
"gitea.dev/modules/git" "gitea.dev/modules/git"
"gitea.dev/modules/git/gitcmd" "gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/setting"
) )
// BatchChecker provides a reader for check-attribute content that can be long running // BatchChecker provides a reader for check-attribute content that can be long running
@@ -120,12 +121,17 @@ func (c *BatchChecker) CheckPath(path string) (rs *Attributes, err error) {
return fmt.Errorf("CheckPath timeout: %s", debugMsg) return fmt.Errorf("CheckPath timeout: %s", debugMsg)
} }
timeout := time.NewTimer(5 * time.Second)
defer timeout.Stop()
rs = NewAttributes() rs = NewAttributes()
for i := 0; i < c.attributesNum; i++ { for i := 0; i < c.attributesNum; i++ {
select { select {
case <-time.After(5 * time.Second): case <-timeout.C:
// there is no "hang" problem now. This code is just used to catch other potential problems. // there is no "hang" problem now. This code is just used to catch other potential problems.
return nil, reportTimeout() err = reportTimeout()
setting.PanicInDevOrTesting("Unexpected timeout, need to investigate: %v", err)
return nil, err
case attr, ok := <-c.stdOut.ReadAttribute(): case attr, ok := <-c.stdOut.ReadAttribute():
if !ok { if !ok {
return nil, c.ctx.Err() return nil, c.ctx.Err()
+2 -2
View File
@@ -46,11 +46,11 @@ func (sf *CommitSubmoduleFile) getWebLinkInTargetRepo(ctx context.Context, moreL
return &SubmoduleWebLink{RepoWebLink: targetLink, CommitWebLink: targetLink + moreLinkPath} return &SubmoduleWebLink{RepoWebLink: targetLink, CommitWebLink: targetLink + moreLinkPath}
} }
if !sf.parsed { if !sf.parsed {
sf.parsed = true
parsedURL, err := giturl.ParseRepositoryURL(ctx, sf.refURL) parsedURL, err := giturl.ParseRepositoryURL(ctx, sf.refURL)
if err != nil { if err != nil {
return nil return nil // do not mark as parsed, otherwise later calls would return a link with an empty target
} }
sf.parsed = true
sf.parsedTargetLink = giturl.MakeRepositoryWebLink(parsedURL) sf.parsedTargetLink = giturl.MakeRepositoryWebLink(parsedURL)
} }
return &SubmoduleWebLink{RepoWebLink: sf.parsedTargetLink, CommitWebLink: sf.parsedTargetLink + moreLinkPath} return &SubmoduleWebLink{RepoWebLink: sf.parsedTargetLink, CommitWebLink: sf.parsedTargetLink + moreLinkPath}
@@ -37,4 +37,11 @@ func TestCommitSubmoduleLink(t *testing.T) {
assert.Equal(t, "/subpath/user/repo", wl.RepoWebLink) assert.Equal(t, "/subpath/user/repo", wl.RepoWebLink)
assert.Equal(t, "/subpath/user/repo/compare/1111...2222", wl.CommitWebLink) assert.Equal(t, "/subpath/user/repo/compare/1111...2222", wl.CommitWebLink)
}) })
t.Run("UnparsableURL", func(t *testing.T) {
// both calls share one instance on purpose: the second one used to see the cached parse result
sf := NewCommitSubmoduleFile("/any/repo-link", "full-path", "git@github.com:", "aaaa")
assert.Nil(t, sf.SubmoduleWebLinkTree(t.Context()))
assert.Nil(t, sf.SubmoduleWebLinkCompare(t.Context(), "1111", "2222"))
})
} }
+10 -5
View File
@@ -49,8 +49,8 @@ type Command struct {
cmd *process.Cmd cmd *process.Cmd
cmdCtx context.Context cmdCtx context.Context
cmdCancel process.CancelCauseFunc cmdCtxCancel process.CancelCauseFunc
cmdFinished process.FinishedFunc cmdFinished func()
cmdStartTime time.Time cmdStartTime time.Time
pipelineFunc func(Context) error pipelineFunc func(Context) error
@@ -428,19 +428,24 @@ func (c *Command) Start(ctx context.Context) (retErr error) {
if c.callerInfo == "" { if c.callerInfo == "" {
c.WithParentCallerInfo() c.WithParentCallerInfo()
} }
// these logs are for debugging purposes only, so no guarantee of correctness or stability // these logs are for debugging purposes only, so no guarantee of correctness or stability
desc := fmt.Sprintf("git.Run(by:%s, repo:%s): %s", c.callerInfo, logArgSanitize(c.gitDir), cmdLogString) desc := fmt.Sprintf("git.Run(by:%s, repo:%s): %s", c.callerInfo, logArgSanitize(c.gitDir), cmdLogString)
log.Debug("git.Command: %s", desc) log.Debug("git.Command: %s", desc)
_, span := gtprof.GetTracer().Start(ctx, gtprof.TraceSpanGitRun) _, span := gtprof.GetTracer().Start(ctx, gtprof.TraceSpanGitRun)
defer span.End()
span.SetAttributeString(gtprof.TraceAttrFuncCaller, c.callerInfo) span.SetAttributeString(gtprof.TraceAttrFuncCaller, c.callerInfo)
span.SetAttributeString(gtprof.TraceAttrGitCommand, cmdLogString) span.SetAttributeString(gtprof.TraceAttrGitCommand, cmdLogString)
var cmdCtxFinished func()
if c.cmdTimeout <= 0 { if c.cmdTimeout <= 0 {
c.cmdCtx, c.cmdCancel, c.cmdFinished = process.GetManager().AddContext(ctx, desc) c.cmdCtx, c.cmdCtxCancel, cmdCtxFinished = process.GetManager().AddContext(ctx, desc)
} else { } else {
c.cmdCtx, c.cmdCancel, c.cmdFinished = process.GetManager().AddContextTimeout(ctx, c.cmdTimeout, desc) c.cmdCtx, c.cmdCtxCancel, cmdCtxFinished = process.GetManager().AddContextTimeout(ctx, c.cmdTimeout, desc)
}
c.cmdFinished = func() {
cmdCtxFinished()
span.End()
} }
c.cmdStartTime = time.Now() c.cmdStartTime = time.Now()
+1 -1
View File
@@ -27,6 +27,6 @@ func (c *cmdContext) CancelPipeline(err error) error {
// * context canceled by pipeline caller with/without error (normal cancellation) // * context canceled by pipeline caller with/without error (normal cancellation)
// * context canceled by parent context (still context.Canceled error) // * context canceled by parent context (still context.Canceled error)
// * other causes // * other causes
c.cmd.cmdCancel(pipelineError{err}) c.cmd.cmdCtxCancel(pipelineError{err})
return err return err
} }
+91 -13
View File
@@ -8,9 +8,13 @@ package git
import ( import (
"errors" "errors"
"io"
"os"
"path/filepath" "path/filepath"
"slices" "slices"
"strings"
"gitea.dev/modules/container"
"gitea.dev/modules/git/gitrepo" "gitea.dev/modules/git/gitrepo"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
@@ -20,6 +24,7 @@ import (
"github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/cache" "github.com/go-git/go-git/v5/plumbing/cache"
"github.com/go-git/go-git/v5/storage/filesystem" "github.com/go-git/go-git/v5/storage/filesystem"
"github.com/go-git/go-git/v5/storage/filesystem/dotgit"
) )
const isGogit = true const isGogit = true
@@ -31,25 +36,98 @@ type Repository struct {
gogitStorage *reindexingStorage gogitStorage *reindexingStorage
} }
// reindexingStorage picks up packs that git wrote after go-git loaded its index // reindexingStorage reloads the pack index when git added or removed packs after go-git loaded it
// https://github.com/go-git/go-git/issues/2439 // https://github.com/go-git/go-git/issues/2439 https://github.com/go-git/go-git/issues/1623
// FIXME: gogit workaround, remove with the gogit build
type reindexingStorage struct { type reindexingStorage struct {
*filesystem.Storage *filesystem.Storage
packs []plumbing.Hash packs []plumbing.Hash
} }
func (s *reindexingStorage) EncodedObject(t plumbing.ObjectType, h plumbing.Hash) (plumbing.EncodedObject, error) { func isRepackError(err error) bool {
obj, err := s.Storage.EncodedObject(t, h) return errors.Is(err, plumbing.ErrObjectNotFound) || errors.Is(err, dotgit.ErrPackfileNotFound) || errors.Is(err, os.ErrNotExist)
if !errors.Is(err, plumbing.ErrObjectNotFound) { }
return obj, err
// retry reruns fn while a concurrent repack keeps changing the packs
func (s *reindexingStorage) retry(fn func() error) error {
for {
err := fn()
if !isRepackError(err) {
return err
}
packs, _ := s.ObjectPacks()
if slices.Equal(packs, s.packs) {
return err
}
s.packs = packs
s.Reindex()
} }
packs, _ := s.ObjectPacks() }
if slices.Equal(packs, s.packs) {
return obj, err func (s *reindexingStorage) EncodedObject(t plumbing.ObjectType, h plumbing.Hash) (obj plumbing.EncodedObject, err error) {
err = s.retry(func() (err error) {
obj, err = s.Storage.EncodedObject(t, h)
return err
})
if err != nil {
return nil, err
} }
s.packs = packs if _, ok := obj.(*plumbing.MemoryObject); ok {
s.Reindex() return obj, nil
return s.Storage.EncodedObject(t, h) }
return &lazyObject{EncodedObject: obj, storage: s}, nil
}
// lazyObject looks up a large object again when its file got removed before Reader reopened it
// FIXME: gogit workaround, remove with the gogit build
type lazyObject struct {
plumbing.EncodedObject
storage *reindexingStorage
}
func (o *lazyObject) Reader() (rc io.ReadCloser, err error) {
rc, err = o.EncodedObject.Reader()
if !isRepackError(err) {
return rc, err
}
err = o.storage.retry(func() error {
obj, err := o.storage.Storage.EncodedObject(o.Type(), o.Hash())
if err == nil {
o.EncodedObject = obj
rc, err = obj.Reader()
}
return err
})
return rc, err
}
// packIdxFS lists packs like git, only while their .idx exists
// FIXME: gogit workaround, remove with the gogit build
type packIdxFS struct {
billy.Filesystem
}
func (f packIdxFS) ReadDir(dir string) ([]os.FileInfo, error) {
if dir != f.Join("objects", "pack") {
return f.Filesystem.ReadDir(dir)
}
dirFile, err := os.Open(filepath.Join(f.Root(), dir))
if err != nil {
return nil, err
}
defer dirFile.Close()
infos, err := dirFile.Readdir(-1) // skips files removed before their lstat, unlike billy's ReadDir
if err != nil {
return nil, err
}
names := make(container.Set[string], len(infos))
for _, info := range infos {
names.Add(info.Name())
}
return slices.DeleteFunc(infos, func(info os.FileInfo) bool {
base, isPack := strings.CutSuffix(info.Name(), ".pack")
return isPack && !names.Contains(base+".idx")
}), nil
} }
func openRepositoryInternal(gitRepo *Repository) error { func openRepositoryInternal(gitRepo *Repository) error {
@@ -71,7 +149,7 @@ func openRepositoryInternal(gitRepo *Repository) error {
altFs = osfs.New("/") altFs = osfs.New("/")
} }
gitRepo.objectFormatCache = ParseGogitHash(plumbing.ZeroHash).Type() gitRepo.objectFormatCache = ParseGogitHash(plumbing.ZeroHash).Type()
storage := filesystem.NewStorageWithOptions(fs, cache.NewObjectLRUDefault(), filesystem.Options{KeepDescriptors: true, LargeObjectThreshold: setting.Git.LargeObjectThreshold, AlternatesFS: altFs}) storage := filesystem.NewStorageWithOptions(packIdxFS{fs}, cache.NewObjectLRUDefault(), filesystem.Options{KeepDescriptors: true, LargeObjectThreshold: setting.Git.LargeObjectThreshold, AlternatesFS: altFs})
packs, _ := storage.ObjectPacks() packs, _ := storage.ObjectPacks()
gitRepo.gogitStorage = &reindexingStorage{Storage: storage, packs: packs} gitRepo.gogitStorage = &reindexingStorage{Storage: storage, packs: packs}
gitRepo.gogitRepo, err = gogit.Open(gitRepo.gogitStorage, fs) gitRepo.gogitRepo, err = gogit.Open(gitRepo.gogitStorage, fs)
+41
View File
@@ -4,9 +4,15 @@
package git package git
import ( import (
"fmt"
"os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/setting"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -39,6 +45,41 @@ func TestRepository_GetBranches(t *testing.T) {
assert.ElementsMatch(t, []string{}, branches) assert.ElementsMatch(t, []string{}, branches)
} }
// FIXME: covers the gogit workarounds in repo_base_gogit.go, remove with the gogit build
func TestReadsAfterConcurrentRepack(t *testing.T) {
repoDir := t.TempDir()
require.NoError(t, gitcmd.NewCommand("init", "--bare").AddDynamicArguments(repoDir).Run(t.Context()))
content := strings.Repeat("a", int(setting.Git.LargeObjectThreshold)+1)
for _, from := range []string{"", "from refs/heads/main^0\n"} {
stdin := fmt.Sprintf("commit refs/heads/main\ncommitter a <a@a> 0 +0000\ndata 0\n%sM 100644 inline f\ndata %d\n%s\n", from, len(content), content)
require.NoError(t, gitcmd.NewCommand("fast-import").WithDir(repoDir).WithStdinBytes([]byte(stdin)).Run(t.Context()))
require.NoError(t, gitcmd.NewCommand("repack", "-d").WithDir(repoDir).Run(t.Context()))
}
repo, err := OpenRepositoryLocal(t.Context(), repoDir)
require.NoError(t, err)
defer repo.Close()
require.False(t, repo.IsObjectExist(t.Context(), "0000000000000000000000000000000000000001"))
blobRepo, err := OpenRepositoryLocal(t.Context(), repoDir)
require.NoError(t, err)
defer blobRepo.Close()
commit, err := blobRepo.GetBranchCommit(t.Context(), "main")
require.NoError(t, err)
readBlob := func() string {
data, err := commit.GetFileContent(t.Context(), blobRepo, "f", len(content))
require.NoError(t, err)
return data
}
require.Equal(t, content, readBlob())
require.NoError(t, gitcmd.NewCommand("repack", "-a", "-d").WithDir(repoDir).Run(t.Context()))
require.NoError(t, os.WriteFile(filepath.Join(repoDir, "objects", "pack", "pack-"+strings.Repeat("1", 40)+".pack"), nil, 0o644))
branches, _, err := repo.GetBranchNames(t.Context(), 0, 0)
require.NoError(t, err)
assert.Equal(t, []string{"main"}, branches)
assert.Equal(t, content, readBlob())
}
func BenchmarkRepository_GetBranches(b *testing.B) { func BenchmarkRepository_GetBranches(b *testing.B) {
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
bareRepo1, err := OpenRepositoryLocal(b.Context(), bareRepo1Path) bareRepo1, err := OpenRepositoryLocal(b.Context(), bareRepo1Path)
+2
View File
@@ -122,6 +122,8 @@ func (t *Tracer) Start(ctx context.Context, spanName string) (context.Context, *
ts.parent = parentSpan ts.parent = parentSpan
} }
// FIXME: this ctx handling is not right. The returned ctx should inherit the ctx passed in, but not from span's internal contexts
// The returned ctx only needs to inherit the values of the internal contexts of spans
parentCtx := ctx parentCtx := ctx
for internalSpanIdx, tsp := range starters { for internalSpanIdx, tsp := range starters {
var internalSpan traceSpanInternal var internalSpan traceSpanInternal
+7 -4
View File
@@ -6,14 +6,17 @@ package gtprof
// Some interesting names could be found in https://github.com/open-telemetry/opentelemetry-go/tree/main/semconv // Some interesting names could be found in https://github.com/open-telemetry/opentelemetry-go/tree/main/semconv
const ( const (
TraceSpanContext = "context"
TraceSpanHTTP = "http" TraceSpanHTTP = "http"
TraceSpanGitRun = "git-run" TraceSpanGitRun = "git-run"
TraceSpanDatabase = "database" TraceSpanDatabase = "database"
) )
const ( const (
TraceAttrFuncCaller = "func.caller" TraceAttrGeneralName = "general.name"
TraceAttrDbSQL = "db.sql" TraceAttrGeneralDesc = "general.desc"
TraceAttrGitCommand = "git.command" TraceAttrFuncCaller = "func.caller"
TraceAttrHTTPRoute = "http.route" TraceAttrDbSQL = "db.sql"
TraceAttrGitCommand = "git.command"
TraceAttrHTTPRoute = "http.route"
) )
+3 -1
View File
@@ -99,7 +99,9 @@ func handleGenericETagTimeCache(req *http.Request, w http.ResponseWriter, etag s
return true return true
} }
} }
if lastModified != nil && !lastModified.IsZero() { // https://www.rfc-editor.org/rfc/rfc9110#section-13.1.3
// A recipient MUST ignore If-Modified-Since if the request contains an If-None-Match header field
if lastModified != nil && !lastModified.IsZero() && req.Header.Get("If-None-Match") == "" {
ifModifiedSince := req.Header.Get("If-Modified-Since") ifModifiedSince := req.Header.Get("If-Modified-Since")
if ifModifiedSince != "" { if ifModifiedSince != "" {
t, err := time.Parse(http.TimeFormat, ifModifiedSince) t, err := time.Parse(http.TimeFormat, ifModifiedSince)
+13
View File
@@ -76,6 +76,19 @@ func TestHandleGenericETagCache(t *testing.T) {
wantHeaders: map[string]string{"Last-Modified": lastModified, "Cache-Control": "", "Etag": weakEtag}, wantHeaders: map[string]string{"Last-Modified": lastModified, "Cache-Control": "", "Etag": weakEtag},
wantStatus: http.StatusNotModified, wantStatus: http.StatusNotModified,
}, },
{
name: "If-Modified-Since without If-None-Match",
reqHeaders: map[string]string{"If-Modified-Since": lastModified},
wantHandled: true,
wantHeaders: map[string]string{"Last-Modified": lastModified, "Cache-Control": "", "Etag": matchedEtag},
wantStatus: http.StatusNotModified,
},
{
name: "Mismatched If-None-Match takes precedence over If-Modified-Since",
reqHeaders: map[string]string{"If-None-Match": `"mismatched-etag"`, "If-Modified-Since": lastModified},
wantHandled: false,
wantHeaders: map[string]string{"Last-Modified": lastModified, "Cache-Control": cacheControl, "Etag": matchedEtag},
},
{ {
name: "Multiple Matched If-None-Match", name: "Multiple Matched If-None-Match",
reqHeaders: map[string]string{"If-None-Match": `"mismatched-etag", ` + matchedEtag}, reqHeaders: map[string]string{"If-None-Match": `"mismatched-etag", ` + matchedEtag},
+4
View File
@@ -45,6 +45,10 @@ func MarshalKeepOptionalEmpty(v any) ([]byte, error) {
return jsonv2.Marshal(v, jsonV2.marshalKeepOptionalEmptyOptions) return jsonv2.Marshal(v, jsonV2.marshalKeepOptionalEmptyOptions)
} }
func MarshalDeterministic(v any) ([]byte, error) {
return jsonv2.Marshal(v, jsonV2.marshalOptions, jsonv2.Deterministic(true))
}
func (j *JSONv2) Marshal(v any) ([]byte, error) { func (j *JSONv2) Marshal(v any) ([]byte, error) {
return jsonv2.Marshal(v, j.marshalOptions) return jsonv2.Marshal(v, j.marshalOptions)
} }
+8 -6
View File
@@ -5,6 +5,7 @@ package markup
import ( import (
"fmt" "fmt"
"net/url"
"strings" "strings"
"gitea.dev/modules/references" "gitea.dev/modules/references"
@@ -26,12 +27,13 @@ func mentionProcessor(ctx *RenderContext, node *html.Node) {
loc.Start += start loc.Start += start
loc.End += start loc.End += start
mention := node.Data[loc.Start:loc.End] mention := node.Data[loc.Start:loc.End]
teams, ok := ctx.RenderOptions.Metas["teams"] orgLowerTeams, checkOrgTeams := ctx.RenderOptions.Metas["teams"] // in format ",team1,team2,...,team-n,", always lowercase
if ok && strings.Contains(mention, "/") { if checkOrgTeams && strings.Contains(mention, "/") {
mentionOrgAndTeam := strings.Split(mention, "/") mentionOrg, teamName, _ := strings.Cut(mention, "/")
if mentionOrgAndTeam[0][1:] == ctx.RenderOptions.Metas["org"] && strings.Contains(teams, ","+strings.ToLower(mentionOrgAndTeam[1])+",") { orgName := mentionOrg[1:] // remove the '@' prefix
link := fmt.Sprintf("/:root/org/%s/teams/%s", ctx.RenderOptions.Metas["org"], mentionOrgAndTeam[1]) if strings.EqualFold(orgName, ctx.RenderOptions.Metas["org"]) && strings.Contains(orgLowerTeams, ","+strings.ToLower(teamName)+",") {
link := fmt.Sprintf("/:root/org/%s/teams/%s", url.PathEscape(orgName), url.PathEscape(teamName))
replaceContent(node, loc.Start, loc.End, createLink(ctx, link, mention, "" /*mention*/)) replaceContent(node, loc.Start, loc.End, createLink(ctx, link, mention, "" /*mention*/))
node = node.NextSibling.NextSibling node = node.NextSibling.NextSibling
start = 0 start = 0
@@ -43,7 +45,7 @@ func mentionProcessor(ctx *RenderContext, node *html.Node) {
mentionedUsername := mention[1:] mentionedUsername := mention[1:]
if DefaultRenderHelperFuncs != nil && DefaultRenderHelperFuncs.IsUsernameMentionable(ctx, mentionedUsername) { if DefaultRenderHelperFuncs != nil && DefaultRenderHelperFuncs.IsUsernameMentionable(ctx, mentionedUsername) {
link := "/:root/" + mentionedUsername link := "/:root/" + url.PathEscape(mentionedUsername)
replaceContent(node, loc.Start, loc.End, createLink(ctx, link, mention, "" /*mention*/)) replaceContent(node, loc.Start, loc.End, createLink(ctx, link, mention, "" /*mention*/))
node = node.NextSibling.NextSibling node = node.NextSibling.NextSibling
start = 0 start = 0
+15
View File
@@ -605,3 +605,18 @@ func TestIssue18471(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, `<a href="`+markup.TestAppURL+`org/repo/compare/783b039...da951ce" class="compare"><code>783b039...da951ce</code></a>`, res.String()) assert.Equal(t, `<a href="`+markup.TestAppURL+`org/repo/compare/783b039...da951ce" class="compare"><code>783b039...da951ce</code></a>`, res.String())
} }
func TestRender_TeamMention(t *testing.T) {
// the "org" meta is lower-cased, see Repository.composeCommonMetas
metas := map[string]string{"user": "Org1", "repo": "repo1", "org": "org1", "teams": ",developers,"}
test := func(input, expected string) {
rctx := markup.NewTestRenderContext(markup.TestAppURL, metas).WithRelativePath("a.md")
buffer, err := testRenderString(rctx, input)
assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
}
test("@org1/developers", `<p><a href="/org/org1/teams/developers" rel="nofollow">@org1/developers</a></p>`)
test("@Org1/Developers", `<p><a href="/org/Org1/teams/Developers" rel="nofollow">@Org1/Developers</a></p>`)
test("@org2/developers", `<p>@org2/developers</p>`)
test("@org1/testers", `<p>@org1/testers</p>`)
}
+23 -51
View File
@@ -121,6 +121,7 @@ type PackageMetadataVersion struct {
Engines map[string]string `json:"engines,omitempty"` Engines map[string]string `json:"engines,omitempty"`
CPU []string `json:"cpu,omitempty"` CPU []string `json:"cpu,omitempty"`
OS []string `json:"os,omitempty"` OS []string `json:"os,omitempty"`
Libc []string `json:"libc,omitempty"`
Directories map[string]string `json:"directories,omitempty"` Directories map[string]string `json:"directories,omitempty"`
Funding any `json:"funding,omitempty"` Funding any `json:"funding,omitempty"`
AcceptDependencies map[string]string `json:"acceptDependencies,omitempty"` AcceptDependencies map[string]string `json:"acceptDependencies,omitempty"`
@@ -129,12 +130,9 @@ type PackageMetadataVersion struct {
// PackageDistribution https://github.com/npm/registry/blob/master/docs/REGISTRY-API.md#version // PackageDistribution https://github.com/npm/registry/blob/master/docs/REGISTRY-API.md#version
type PackageDistribution struct { type PackageDistribution struct {
Integrity string `json:"integrity"` Integrity string `json:"integrity"`
Shasum string `json:"shasum"` Shasum string `json:"shasum"`
Tarball string `json:"tarball"` Tarball string `json:"tarball"`
FileCount int `json:"fileCount,omitempty"`
UnpackedSize int `json:"unpackedSize,omitempty"`
NpmSignature string `json:"npm-signature,omitempty"`
} }
type PackageSearch struct { type PackageSearch struct {
@@ -226,7 +224,7 @@ func (r *Repository) UnmarshalJSON(data []byte) error {
} }
// Bin maps command names to executable files. npm also allows a single string, // Bin maps command names to executable files. npm also allows a single string,
// in which case the command is named after the package (resolved in ParsePackage). // in which case the command is named after the package (resolved in parseUploadPackage).
type Bin map[string]string type Bin map[string]string
// UnmarshalJSON is needed because the bin field can be a string or an object. // UnmarshalJSON is needed because the bin field can be a string or an object.
@@ -264,7 +262,7 @@ type packageUpload struct {
// is non-nil on success; a body without `_attachments` is a deprecate request, // is non-nil on success; a body without `_attachments` is a deprecate request,
// otherwise it is a "publish". // otherwise it is a "publish".
func ParseUpload(r io.Reader) (*Package, *PackageDeprecation, error) { func ParseUpload(r io.Reader) (*Package, *PackageDeprecation, error) {
body, err := io.ReadAll(io.LimitReader(r, 10*1024*1024)) body, err := io.ReadAll(r)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@@ -280,16 +278,6 @@ func ParseUpload(r io.Reader) (*Package, *PackageDeprecation, error) {
return p, nil, err return p, nil, err
} }
// ParsePackage parses a npm publish PUT body. Bodies without `_attachments`
// surface as ErrInvalidAttachment once name/version validation has passed.
func ParsePackage(r io.Reader) (*Package, error) {
var upload packageUpload
if err := json.NewDecoder(r).Decode(&upload); err != nil {
return nil, err
}
return parseUploadPackage(&upload)
}
// parseUploadPackage builds a Package from a decoded publish body. // parseUploadPackage builds a Package from a decoded publish body.
func parseUploadPackage(upload *packageUpload) (*Package, error) { func parseUploadPackage(upload *packageUpload) (*Package, error) {
for _, meta := range upload.Versions { for _, meta := range upload.Versions {
@@ -343,6 +331,7 @@ func parseUploadPackage(upload *packageUpload) (*Package, error) {
Engines: meta.Engines, Engines: meta.Engines,
CPU: meta.CPU, CPU: meta.CPU,
OS: meta.OS, OS: meta.OS,
Libc: meta.Libc,
Directories: meta.Directories, Directories: meta.Directories,
Funding: meta.Funding, Funding: meta.Funding,
AcceptDependencies: meta.AcceptDependencies, AcceptDependencies: meta.AcceptDependencies,
@@ -356,12 +345,12 @@ func parseUploadPackage(upload *packageUpload) (*Package, error) {
p.Filename = strings.ToLower(fmt.Sprintf("%s-%s.tgz", name, p.Version)) p.Filename = strings.ToLower(fmt.Sprintf("%s-%s.tgz", name, p.Version))
attachment := func() *PackageAttachment { attachment := upload.Attachments[meta.Name+"-"+meta.Version+".tgz"] // not the sigstore bundle of `npm publish --provenance`
if attachment == nil && len(upload.Attachments) == 1 {
for _, a := range upload.Attachments { for _, a := range upload.Attachments {
return a attachment = a
} }
return nil }
}()
if attachment == nil || len(attachment.Data) == 0 { if attachment == nil || len(attachment.Data) == 0 {
return nil, ErrInvalidAttachment return nil, ErrInvalidAttachment
} }
@@ -393,8 +382,6 @@ func parseUploadPackage(upload *packageUpload) (*Package, error) {
return nil, ErrInvalidIntegrity return nil, ErrInvalidIntegrity
} }
// Derive _hasShrinkwrap and hasInstallScript from the tarball; the
// packument can lie about either.
p.Metadata.HasShrinkwrap, p.Metadata.HasInstallScript = inspectTarball(data) p.Metadata.HasShrinkwrap, p.Metadata.HasInstallScript = inspectTarball(data)
return p, nil return p, nil
@@ -410,11 +397,7 @@ const maxNpmTarballScanBytes = int64(32 * 1024 * 1024) // 32 MiB
// maxNpmPackageJSONBytes caps the package.json bytes decoded from the tarball. // maxNpmPackageJSONBytes caps the package.json bytes decoded from the tarball.
const maxNpmPackageJSONBytes = int64(1 * 1024 * 1024) // 1 MiB const maxNpmPackageJSONBytes = int64(1 * 1024 * 1024) // 1 MiB
// inspectTarball reports hasShrinkwrap (presence of package/npm-shrinkwrap.json) // inspectTarball trusts the tarball over the client's packument, read errors yield zero values to not block publishing
// and hasInstallScript (package/package.json declares any of preinstall,
// install, postinstall). Both must be derived server-side because the client
// can lie in the packument. Any read/decode error yields (false, false) so a
// malformed archive does not block publishing.
func inspectTarball(data []byte) (hasShrinkwrap, hasInstallScript bool) { func inspectTarball(data []byte) (hasShrinkwrap, hasInstallScript bool) {
gr, err := gzip.NewReader(bytes.NewReader(data)) gr, err := gzip.NewReader(bytes.NewReader(data))
if err != nil { if err != nil {
@@ -422,11 +405,16 @@ func inspectTarball(data []byte) (hasShrinkwrap, hasInstallScript bool) {
} }
defer gr.Close() defer gr.Close()
var hasGypFile bool
var pkg struct {
Scripts map[string]string `json:"scripts"`
Gypfile any `json:"gypfile"`
}
tr := tar.NewReader(io.LimitReader(gr, maxNpmTarballScanBytes)) tr := tar.NewReader(io.LimitReader(gr, maxNpmTarballScanBytes))
for { for {
hdr, err := tr.Next() hdr, err := tr.Next()
if err != nil { if err != nil {
return hasShrinkwrap, hasInstallScript break
} }
// npm pack puts files under a single root directory (usually "package/"). // npm pack puts files under a single root directory (usually "package/").
name := strings.TrimPrefix(hdr.Name, "./") name := strings.TrimPrefix(hdr.Name, "./")
@@ -436,30 +424,14 @@ func inspectTarball(data []byte) (hasShrinkwrap, hasInstallScript bool) {
switch { switch {
case strings.HasSuffix(name, "/npm-shrinkwrap.json"): case strings.HasSuffix(name, "/npm-shrinkwrap.json"):
hasShrinkwrap = true hasShrinkwrap = true
case strings.HasSuffix(name, ".gyp"):
hasGypFile = true
case strings.HasSuffix(name, "/package.json"): case strings.HasSuffix(name, "/package.json"):
hasInstallScript = tarballDeclaresInstallScript(tr) _ = json.NewDecoder(io.LimitReader(tr, maxNpmPackageJSONBytes)).Decode(&pkg)
}
if hasShrinkwrap && hasInstallScript {
return hasShrinkwrap, hasInstallScript
} }
} }
} // npm publish adds "install": "node-gyp rebuild" for a root *.gyp file to the manifest, but not to the tarball
return hasShrinkwrap, strings.TrimSpace(pkg.Scripts["preinstall"]+pkg.Scripts["install"]+pkg.Scripts["postinstall"]) != "" || hasGypFile && pkg.Gypfile != false
// tarballDeclaresInstallScript reports whether a package.json declares any
// of preinstall, install, postinstall.
func tarballDeclaresInstallScript(r io.Reader) bool {
var pkg struct {
Scripts map[string]string `json:"scripts"`
}
if err := json.NewDecoder(io.LimitReader(r, maxNpmPackageJSONBytes)).Decode(&pkg); err != nil {
return false
}
for _, name := range []string{"preinstall", "install", "postinstall"} {
if strings.TrimSpace(pkg.Scripts[name]) != "" {
return true
}
}
return false
} }
func validateName(name string) bool { func validateName(name string) bool {
+24 -54
View File
@@ -41,21 +41,20 @@ func TestParsePackage(t *testing.T) {
integrity := "sha512-" + base64Sha512(dataBytes) integrity := "sha512-" + base64Sha512(dataBytes)
t.Run("InvalidUpload", func(t *testing.T) { t.Run("InvalidUpload", func(t *testing.T) {
p, err := ParsePackage(bytes.NewReader([]byte{0})) p, _, err := ParseUpload(bytes.NewReader([]byte{0}))
assert.Nil(t, p) assert.Nil(t, p)
assert.Error(t, err) assert.Error(t, err)
}) })
t.Run("InvalidUploadNoData", func(t *testing.T) { t.Run("InvalidUploadNoData", func(t *testing.T) {
b, _ := json.Marshal(packageUpload{}) p, err := parseUploadPackage(&packageUpload{})
p, err := ParsePackage(bytes.NewReader(b))
assert.Nil(t, p) assert.Nil(t, p)
assert.ErrorIs(t, err, ErrInvalidPackage) assert.ErrorIs(t, err, ErrInvalidPackage)
}) })
t.Run("InvalidPackageName", func(t *testing.T) { t.Run("InvalidPackageName", func(t *testing.T) {
test := func(t *testing.T, name string) { test := func(t *testing.T, name string) {
b, _ := json.Marshal(packageUpload{ p, err := parseUploadPackage(&packageUpload{
PackageMetadata: PackageMetadata{ PackageMetadata: PackageMetadata{
ID: name, ID: name,
Name: name, Name: name,
@@ -66,8 +65,6 @@ func TestParsePackage(t *testing.T) {
}, },
}, },
}) })
p, err := ParsePackage(bytes.NewReader(b))
assert.Nil(t, p) assert.Nil(t, p)
assert.ErrorIs(t, err, ErrInvalidPackageName) assert.ErrorIs(t, err, ErrInvalidPackageName)
} }
@@ -94,7 +91,7 @@ func TestParsePackage(t *testing.T) {
t.Run("ValidPackageName", func(t *testing.T) { t.Run("ValidPackageName", func(t *testing.T) {
test := func(t *testing.T, name string) { test := func(t *testing.T, name string) {
b, _ := json.Marshal(packageUpload{ p, err := parseUploadPackage(&packageUpload{
PackageMetadata: PackageMetadata{ PackageMetadata: PackageMetadata{
ID: name, ID: name,
Name: name, Name: name,
@@ -105,8 +102,6 @@ func TestParsePackage(t *testing.T) {
}, },
}, },
}) })
p, err := ParsePackage(bytes.NewReader(b))
assert.Nil(t, p) assert.Nil(t, p)
assert.ErrorIs(t, err, ErrInvalidPackageVersion) assert.ErrorIs(t, err, ErrInvalidPackageVersion)
} }
@@ -125,7 +120,7 @@ func TestParsePackage(t *testing.T) {
t.Run("InvalidPackageVersion", func(t *testing.T) { t.Run("InvalidPackageVersion", func(t *testing.T) {
version := "first-version" version := "first-version"
b, _ := json.Marshal(packageUpload{ p, err := parseUploadPackage(&packageUpload{
PackageMetadata: PackageMetadata{ PackageMetadata: PackageMetadata{
ID: packageFullName, ID: packageFullName,
Name: packageFullName, Name: packageFullName,
@@ -137,8 +132,6 @@ func TestParsePackage(t *testing.T) {
}, },
}, },
}) })
p, err := ParsePackage(bytes.NewReader(b))
assert.Nil(t, p) assert.Nil(t, p)
assert.ErrorIs(t, err, ErrInvalidPackageVersion) assert.ErrorIs(t, err, ErrInvalidPackageVersion)
}) })
@@ -160,7 +153,7 @@ func TestParsePackage(t *testing.T) {
}, },
}) })
p, err := ParsePackage(bytes.NewReader(b)) p, _, err := ParseUpload(bytes.NewReader(b))
assert.Nil(t, p) assert.Nil(t, p)
assert.ErrorIs(t, err, ErrInvalidAttachment) assert.ErrorIs(t, err, ErrInvalidAttachment)
}) })
@@ -185,7 +178,7 @@ func TestParsePackage(t *testing.T) {
}, },
}) })
p, err := ParsePackage(bytes.NewReader(b)) p, _, err := ParseUpload(bytes.NewReader(b))
assert.Nil(t, p) assert.Nil(t, p)
assert.ErrorIs(t, err, ErrInvalidAttachment) assert.ErrorIs(t, err, ErrInvalidAttachment)
}) })
@@ -213,7 +206,7 @@ func TestParsePackage(t *testing.T) {
}, },
}) })
p, err := ParsePackage(bytes.NewReader(b)) p, _, err := ParseUpload(bytes.NewReader(b))
assert.Nil(t, p) assert.Nil(t, p)
assert.ErrorIs(t, err, ErrInvalidIntegrity) assert.ErrorIs(t, err, ErrInvalidIntegrity)
}) })
@@ -241,7 +234,7 @@ func TestParsePackage(t *testing.T) {
}, },
}) })
p, err := ParsePackage(bytes.NewReader(b)) p, _, err := ParseUpload(bytes.NewReader(b))
assert.Nil(t, p) assert.Nil(t, p)
assert.ErrorIs(t, err, ErrInvalidIntegrity) assert.ErrorIs(t, err, ErrInvalidIntegrity)
}) })
@@ -281,10 +274,13 @@ func TestParsePackage(t *testing.T) {
filename: { filename: {
Data: data, Data: data,
}, },
packageFullName + "-" + packageVersion + ".sigstore": {
Data: "{}",
},
}, },
}) })
p, err := ParsePackage(bytes.NewReader(b)) p, _, err := ParseUpload(bytes.NewReader(b))
assert.NotNil(t, p) assert.NotNil(t, p)
assert.NoError(t, err) assert.NoError(t, err)
@@ -329,7 +325,7 @@ func TestParsePackage(t *testing.T) {
} }
} }
}` }`
p, err := ParsePackage(strings.NewReader(packageJSON)) p, _, err := ParseUpload(strings.NewReader(packageJSON))
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "MIT", string(p.Metadata.License)) require.Equal(t, "MIT", string(p.Metadata.License))
}) })
@@ -354,7 +350,7 @@ func TestParsePackage(t *testing.T) {
} }
} }
}` }`
p, err := ParsePackage(strings.NewReader(packageJSON)) p, _, err := ParseUpload(strings.NewReader(packageJSON))
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "https://gitea.io/gitea/test.git", p.Metadata.Repository.URL) require.Equal(t, "https://gitea.io/gitea/test.git", p.Metadata.Repository.URL)
// a string bin is named after the package // a string bin is named after the package
@@ -426,6 +422,15 @@ func TestInspectTarball(t *testing.T) {
// npm pack sometimes emits "./package/..." entries. // npm pack sometimes emits "./package/..." entries.
wantShrinkwrap: true, wantShrinkwrap: true,
}, },
{
name: "gyp file implies node-gyp install",
files: map[string]string{"package/binding.gyp": "{}"},
wantInstaller: true,
},
{
name: "gypfile false disables gyp install",
files: map[string]string{"package/binding.gyp": "{}", "package/package.json": `{"gypfile":false}`},
},
} }
for _, c := range cases { for _, c := range cases {
t.Run(c.name, func(t *testing.T) { t.Run(c.name, func(t *testing.T) {
@@ -460,41 +465,6 @@ func TestParseUpload(t *testing.T) {
require.NotNil(t, dep) require.NotNil(t, dep)
assert.Equal(t, map[string]string{"1.0.0": "gone", "1.0.1": ""}, dep.Versions) assert.Equal(t, map[string]string{"1.0.0": "gone", "1.0.1": ""}, dep.Versions)
}) })
t.Run("dispatches publish when _attachments present", func(t *testing.T) {
// Reuse a minimal tarball with a package.json.
data := buildTarball(map[string]string{"package/package.json": `{}`})
integrity := "sha512-" + base64Sha512(data)
body := fmt.Sprintf(
`{"name":%q,"versions":{"1.0.0":{"name":%q,"version":"1.0.0","dist":{"integrity":%q}}},"_attachments":{"x.tgz":{"data":%q}}}`,
pkg, pkg, integrity, base64.StdEncoding.EncodeToString(data),
)
p, dep, err := ParseUpload(strings.NewReader(body))
require.NoError(t, err)
assert.Nil(t, dep)
require.NotNil(t, p)
assert.Equal(t, pkg, p.Name)
})
t.Run("publish whose readme mentions deprecated is not misrouted", func(t *testing.T) {
// The old fast-path used a substring check for "deprecated"; make sure
// the new dispatch keys off _attachments only.
data := buildTarball(map[string]string{"package/package.json": `{}`})
integrity := "sha512-" + base64Sha512(data)
body := fmt.Sprintf(
`{"name":%q,"versions":{"1.0.0":{"name":%q,"version":"1.0.0","readme":"this package is deprecated!","dist":{"integrity":%q}}},"_attachments":{"x.tgz":{"data":%q}}}`,
pkg, pkg, integrity, base64.StdEncoding.EncodeToString(data),
)
p, dep, err := ParseUpload(strings.NewReader(body))
require.NoError(t, err)
assert.Nil(t, dep)
require.NotNil(t, p)
})
t.Run("invalid json errors out", func(t *testing.T) {
_, _, err := ParseUpload(strings.NewReader("not json"))
assert.Error(t, err)
})
} }
func base64Sha512(data []byte) string { func base64Sha512(data []byte) string {
+1
View File
@@ -29,6 +29,7 @@ type Metadata struct {
Engines map[string]string `json:"engines,omitempty"` Engines map[string]string `json:"engines,omitempty"`
CPU []string `json:"cpu,omitempty"` CPU []string `json:"cpu,omitempty"`
OS []string `json:"os,omitempty"` OS []string `json:"os,omitempty"`
Libc []string `json:"libc,omitempty"`
Directories map[string]string `json:"directories,omitempty"` Directories map[string]string `json:"directories,omitempty"`
Funding any `json:"funding,omitempty"` Funding any `json:"funding,omitempty"`
AcceptDependencies map[string]string `json:"accept_dependencies,omitempty"` AcceptDependencies map[string]string `json:"accept_dependencies,omitempty"`
+1 -1
View File
@@ -240,7 +240,7 @@ func FindAllMentionsBytes(content []byte) []RefSpan {
} }
// FindFirstMentionBytes matches the first mention in then given content // FindFirstMentionBytes matches the first mention in then given content
// and returns the location of the unvalidated user name, including the @ prefix. // and returns the location of the unvalidated username, including the @ prefix.
func FindFirstMentionBytes(content []byte) (bool, RefSpan) { func FindFirstMentionBytes(content []byte) (bool, RefSpan) {
mention := mentionPattern.FindSubmatchIndex(content) mention := mentionPattern.FindSubmatchIndex(content)
if mention == nil { if mention == nil {
-9
View File
@@ -102,8 +102,6 @@ var (
AcmeEmail string AcmeEmail string
AcmeURL string AcmeURL string
AcmeProfile string AcmeProfile string
AcmeEABKID string
AcmeEABHMAC string
AcmeCARoot string AcmeCARoot string
SSLMinimumVersion string SSLMinimumVersion string
SSLMaximumVersion string SSLMaximumVersion string
@@ -146,11 +144,6 @@ func loadServerDomainAndURL(sec ConfigSection, protocol string) {
AppDomain = appURL.Hostname() AppDomain = appURL.Hostname()
} }
func loadAcmeEABFrom(sec ConfigSection) {
AcmeEABKID = sec.Key("ACME_EAB_KID").MustString("")
AcmeEABHMAC = sec.Key("ACME_EAB_HMAC").MustString("")
}
func loadServerFrom(rootCfg ConfigProvider) { func loadServerFrom(rootCfg ConfigProvider) {
sec := rootCfg.Section("server") sec := rootCfg.Section("server")
AppName = rootCfg.Section("").Key("APP_NAME").MustString("Gitea: Git with a cup of tea") AppName = rootCfg.Section("").Key("APP_NAME").MustString("Gitea: Git with a cup of tea")
@@ -180,7 +173,6 @@ func loadServerFrom(rootCfg ConfigProvider) {
if EnableAcme { if EnableAcme {
AcmeURL = sec.Key("ACME_URL").MustString("") AcmeURL = sec.Key("ACME_URL").MustString("")
AcmeProfile = sec.Key("ACME_PROFILE").MustString("") AcmeProfile = sec.Key("ACME_PROFILE").MustString("")
loadAcmeEABFrom(sec)
AcmeCARoot = sec.Key("ACME_CA_ROOT").MustString("") AcmeCARoot = sec.Key("ACME_CA_ROOT").MustString("")
if sec.HasKey("ACME_ACCEPTTOS") { if sec.HasKey("ACME_ACCEPTTOS") {
@@ -216,7 +208,6 @@ func loadServerFrom(rootCfg ConfigProvider) {
KeyFile = filepath.Join(CustomPath, KeyFile) KeyFile = filepath.Join(CustomPath, KeyFile)
} }
} }
SSLMinimumVersion = sec.Key("SSL_MIN_VERSION").MustString("") SSLMinimumVersion = sec.Key("SSL_MIN_VERSION").MustString("")
SSLMaximumVersion = sec.Key("SSL_MAX_VERSION").MustString("") SSLMaximumVersion = sec.Key("SSL_MAX_VERSION").MustString("")
SSLCurvePreferences = sec.Key("SSL_CURVE_PREFERENCES").Strings(",") SSLCurvePreferences = sec.Key("SSL_CURVE_PREFERENCES").Strings(",")
-29
View File
@@ -1,29 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package setting
import (
"testing"
"gitea.dev/modules/test"
"github.com/stretchr/testify/assert"
)
func TestLoadAcmeEABFrom(t *testing.T) {
t.Cleanup(test.MockVariableValue(&AcmeEABKID, ""))
t.Cleanup(test.MockVariableValue(&AcmeEABHMAC, ""))
cfg, err := NewConfigProviderFromData(`
[server]
ACME_EAB_KID = kid
ACME_EAB_HMAC = hmac
`)
assert.NoError(t, err)
loadAcmeEABFrom(cfg.Section("server"))
assert.Equal(t, "kid", AcmeEABKID)
assert.Equal(t, "hmac", AcmeEABHMAC)
}
+2 -1
View File
@@ -36,6 +36,7 @@ import "strings"
const ( const (
tildePrefix = '~' tildePrefix = '~'
commentPrefix = '#'
needsEscape = " \t\n|&;()<>${}[]*?!\"'`\\" needsEscape = " \t\n|&;()<>${}[]*?!\"'`\\"
needsSingleQuote = "!\n" needsSingleQuote = "!\n"
) )
@@ -74,7 +75,7 @@ func ShellEscape(toEscape string) string {
} }
// Now for simplicity we'll look at the rest of the string // Now for simplicity we'll look at the rest of the string
if !strings.ContainsAny(toEscape[start:], needsEscape) { if !strings.ContainsAny(toEscape[start:], needsEscape) && toEscape[0] != commentPrefix {
return toEscape return toEscape
} }
+4
View File
@@ -75,6 +75,10 @@ func TestShellEscape(t *testing.T) {
"Double quote and escape `...", "Double quote and escape `...",
"~/gitea`", "~/gitea`",
"~/\"gitea\\`\"", "~/\"gitea\\`\"",
}, {
"Double quote leading #",
"#123",
`"#123"`,
}, { }, {
"Double quotes can handle a number of things without having to escape them but not everything ...", "Double quotes can handle a number of things without having to escape them but not everything ...",
"~/<gitea> ${gitea} `gitea` [gitea] (gitea) \"gitea\" \\gitea\\ 'gitea'", "~/<gitea> ${gitea} `gitea` [gitea] (gitea) \"gitea\" \\gitea\\ 'gitea'",
+1
View File
@@ -433,6 +433,7 @@
"auth.authorize_application_created_by": "This application was created by %s.", "auth.authorize_application_created_by": "This application was created by %s.",
"auth.authorize_application_description": "If you grant access, it will be able to access and write to all your account information, including private repos and organizations.", "auth.authorize_application_description": "If you grant access, it will be able to access and write to all your account information, including private repos and organizations.",
"auth.authorize_application_with_scopes": "With scopes: %s", "auth.authorize_application_with_scopes": "With scopes: %s",
"auth.authorize_application_new_scopes": "New scopes: %s",
"auth.authorize_title": "Authorize \"%s\" to access your account?", "auth.authorize_title": "Authorize \"%s\" to access your account?",
"auth.authorization_failed": "Authorization failed", "auth.authorization_failed": "Authorization failed",
"auth.authorization_failed_desc": "The authorization failed because we detected an invalid request. Please contact the maintainer of the app you tried to authorize.", "auth.authorization_failed_desc": "The authorization failed because we detected an invalid request. Please contact the maintainer of the app you tried to authorize.",
+4 -1
View File
@@ -406,12 +406,15 @@ func CommonRoutes() *web.Router {
}) })
r.Group("/npm", func() { r.Group("/npm", func() {
r.Get("/-/v1/search", npm.PackageSearch) r.Get("/-/v1/search", npm.PackageSearch)
r.Get("/-/ping", npm.Ping)
r.Get("/-/whoami", npm.Whoami)
r.PathGroup("/*", func(g *web.RouterPathGroup) { r.PathGroup("/*", func(g *web.RouterPathGroup) {
// HINT: NPM-ROUTE-PATH-PATTERN: search this keyword to see more details // HINT: NPM-ROUTE-PATH-PATTERN: search this keyword to see more details
packageId := `/<id:(@` + npm_module.RegexpNamePart + `/)?` + npm_module.RegexpNamePart + ">" packageId := `/<id:(@` + npm_module.RegexpNamePart + `/)?` + npm_module.RegexpNamePart + ">"
g.UseUnescapedPath() g.UseUnescapedPath()
g.MatchPath("DELETE", packageId+"/-/<version>/<filename>/-rev/<revision>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackageVersion) g.MatchPath("DELETE", packageId+"/-/<version>/<filename>/-rev/<revision>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackageVersion)
g.MatchPath("GET", packageId+"/-/<version>/<filename>", npm.DownloadPackageFile) g.MatchPath("DELETE", packageId+"/-/<filename>/-rev/<revision>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackageVersion)
g.MatchPath("GET", packageId+"/-/<version>/<filename>", npm.DownloadPackageFileByName) // former tarball URL, still in lockfiles
g.MatchPath("GET", packageId+"/-/<filename>", npm.DownloadPackageFileByName) g.MatchPath("GET", packageId+"/-/<filename>", npm.DownloadPackageFileByName)
g.MatchPath("DELETE", packageId+"/-rev/<revision>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackage) g.MatchPath("DELETE", packageId+"/-rev/<revision>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackage)
g.MatchPath("PUT", packageId+"/-rev/<revision>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePreview) g.MatchPath("PUT", packageId+"/-rev/<revision>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePreview)
+17 -4
View File
@@ -8,7 +8,7 @@ import (
"encoding/base64" "encoding/base64"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"net/url" "slices"
"sort" "sort"
"time" "time"
@@ -25,6 +25,7 @@ func createPackageMetadataResponse(registryURL string, pds []*packages_model.Pac
distTags := make(map[string]string) distTags := make(map[string]string)
times := make(map[string]time.Time) times := make(map[string]time.Time)
firstPublished, lastPublished := pds[0].Version.CreatedUnix, pds[0].Version.CreatedUnix firstPublished, lastPublished := pds[0].Version.CreatedUnix, pds[0].Version.CreatedUnix
var latest *packages_model.PackageDescriptor
for _, pd := range pds { for _, pd := range pds {
semVer := pd.SemVer.String() semVer := pd.SemVer.String()
versions[semVer] = createPackageMetadataVersion(registryURL, pd) versions[semVer] = createPackageMetadataVersion(registryURL, pd)
@@ -35,6 +36,9 @@ func createPackageMetadataResponse(registryURL string, pds []*packages_model.Pac
for _, pvp := range pd.VersionProperties { for _, pvp := range pd.VersionProperties {
if pvp.Name == npm_module.TagProperty { if pvp.Name == npm_module.TagProperty {
distTags[pvp.Value] = pd.Version.Version distTags[pvp.Value] = pd.Version.Version
if pvp.Value == "latest" {
latest = pd
}
} }
} }
} }
@@ -43,7 +47,16 @@ func createPackageMetadataResponse(registryURL string, pds []*packages_model.Pac
times["created"] = firstPublished.AsTimeInLocation(time.UTC) times["created"] = firstPublished.AsTimeInLocation(time.UTC)
times["modified"] = lastPublished.AsTimeInLocation(time.UTC) times["modified"] = lastPublished.AsTimeInLocation(time.UTC)
latest := pds[len(pds)-1] if latest == nil { // yarn and pnpm fail without it, e.g. after its version got deleted
latest = pds[len(pds)-1]
for _, pd := range slices.Backward(pds) {
if pd.SemVer.Prerelease() == "" {
latest = pd
break
}
}
distTags["latest"] = latest.Version.Version
}
metadata := packages_model.DescriptorMetadata[*npm_module.Metadata](latest) metadata := packages_model.DescriptorMetadata[*npm_module.Metadata](latest)
@@ -86,13 +99,13 @@ func createPackageMetadataVersion(registryURL string, pd *packages_model.Package
PeerDependencies: metadata.PeerDependencies, PeerDependencies: metadata.PeerDependencies,
PeerDependenciesMeta: metadata.PeerDependenciesMeta, PeerDependenciesMeta: metadata.PeerDependenciesMeta,
OptionalDependencies: metadata.OptionalDependencies, OptionalDependencies: metadata.OptionalDependencies,
Readme: metadata.Readme,
Bin: metadata.Bin, Bin: metadata.Bin,
HasInstallScript: metadata.HasInstallScript, HasInstallScript: metadata.HasInstallScript,
HasShrinkwrap: metadata.HasShrinkwrap, HasShrinkwrap: metadata.HasShrinkwrap,
Engines: metadata.Engines, Engines: metadata.Engines,
CPU: metadata.CPU, CPU: metadata.CPU,
OS: metadata.OS, OS: metadata.OS,
Libc: metadata.Libc,
Directories: metadata.Directories, Directories: metadata.Directories,
Funding: metadata.Funding, Funding: metadata.Funding,
AcceptDependencies: metadata.AcceptDependencies, AcceptDependencies: metadata.AcceptDependencies,
@@ -100,7 +113,7 @@ func createPackageMetadataVersion(registryURL string, pd *packages_model.Package
Dist: npm_module.PackageDistribution{ Dist: npm_module.PackageDistribution{
Shasum: pd.Files[0].Blob.HashSHA1, Shasum: pd.Files[0].Blob.HashSHA1,
Integrity: "sha512-" + base64.StdEncoding.EncodeToString(hashBytes), Integrity: "sha512-" + base64.StdEncoding.EncodeToString(hashBytes),
Tarball: fmt.Sprintf("%s/%s/-/%s/%s", registryURL, url.PathEscape(pd.Package.Name), url.PathEscape(pd.Version.Version), url.PathEscape(pd.Files[0].File.LowerName)), Tarball: fmt.Sprintf("%s/%s/-/%s", registryURL, pd.Package.Name, pd.Files[0].File.LowerName), // npmjs shape, which npm parses for allowScripts and yarn keeps registry-relative
}, },
} }
} }
+11 -6
View File
@@ -25,7 +25,7 @@ func TestCreatePackageMetadataResponse(t *testing.T) {
Owner: &user_model.User{Name: "alice"}, Owner: &user_model.User{Name: "alice"},
Version: &packages_model.PackageVersion{Version: v, CreatedUnix: timeutil.TimeStamp(publishedUnix)}, Version: &packages_model.PackageVersion{Version: v, CreatedUnix: timeutil.TimeStamp(publishedUnix)},
SemVer: version.Must(version.NewVersion(v)), SemVer: version.Must(version.NewVersion(v)),
Metadata: &npm_module.Metadata{Keywords: []string{"gitea"}, Repository: repo}, Metadata: &npm_module.Metadata{Readme: v, Keywords: []string{"gitea"}, Repository: repo},
Files: []*packages_model.PackageFileDescriptor{{ Files: []*packages_model.PackageFileDescriptor{{
File: &packages_model.PackageFile{LowerName: "test-" + v + ".tgz"}, File: &packages_model.PackageFile{LowerName: "test-" + v + ".tgz"},
Blob: &packages_model.PackageBlob{}, Blob: &packages_model.PackageBlob{},
@@ -35,21 +35,26 @@ func TestCreatePackageMetadataResponse(t *testing.T) {
result := createPackageMetadataResponse("https://gitea.dev/api/packages/alice/npm", []*packages_model.PackageDescriptor{ result := createPackageMetadataResponse("https://gitea.dev/api/packages/alice/npm", []*packages_model.PackageDescriptor{
descriptor("1.1.0", 1000, npm_module.Repository{}), descriptor("1.1.0", 1000, npm_module.Repository{}),
descriptor("2.0.0-rc.1", 1500, repository),
descriptor("1.0.0", 2000, repository), descriptor("1.0.0", 2000, repository),
}) })
assert.Equal(t, map[string]time.Time{ assert.Equal(t, map[string]time.Time{
"1.0.0": time.Unix(2000, 0).UTC(), "1.0.0": time.Unix(2000, 0).UTC(),
"1.1.0": time.Unix(1000, 0).UTC(), "1.1.0": time.Unix(1000, 0).UTC(),
"created": time.Unix(1000, 0).UTC(), "2.0.0-rc.1": time.Unix(1500, 0).UTC(),
"modified": time.Unix(2000, 0).UTC(), "created": time.Unix(1000, 0).UTC(),
"modified": time.Unix(2000, 0).UTC(),
}, result.Time) }, result.Time)
assert.Equal(t, map[string]string{"latest": "1.1.0"}, result.DistTags)
assert.Equal(t, "1.1.0", result.Readme)
assert.Empty(t, result.Versions["1.1.0"].Readme)
assert.Equal(t, []npm_module.User{{Name: "alice"}}, result.Maintainers) assert.Equal(t, []npm_module.User{{Name: "alice"}}, result.Maintainers)
assert.Equal(t, []string{"gitea"}, result.Keywords) assert.Equal(t, []string{"gitea"}, result.Keywords)
assert.Equal(t, []string{"gitea"}, result.Versions["1.0.0"].Keywords) assert.Equal(t, []string{"gitea"}, result.Versions["1.0.0"].Keywords)
assert.Equal(t, []npm_module.User{{Name: "alice"}}, result.Versions["1.0.0"].Maintainers) assert.Equal(t, []npm_module.User{{Name: "alice"}}, result.Versions["1.0.0"].Maintainers)
assert.Equal(t, assert.Equal(t,
"https://gitea.dev/api/packages/alice/npm/@scope%2Ftest/-/1.0.0/test-1.0.0.tgz", "https://gitea.dev/api/packages/alice/npm/@scope/test/-/test-1.0.0.tgz",
result.Versions["1.0.0"].Dist.Tarball, result.Versions["1.0.0"].Dist.Tarball,
) )
assert.Equal(t, repository, result.Versions["1.0.0"].Repository) assert.Equal(t, repository, result.Versions["1.0.0"].Repository)
+88 -92
View File
@@ -6,7 +6,9 @@ package npm
import ( import (
"bytes" "bytes"
std_ctx "context" std_ctx "context"
"crypto/sha256"
"errors" "errors"
"fmt"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
@@ -55,28 +57,41 @@ func buildNpmRegistryURL(ctx std_ctx.Context, owner *user_model.User) string {
return httplib.GuessCurrentAppURL(ctx) + "api/packages/" + url.PathEscape(owner.Name) + "/npm" return httplib.GuessCurrentAppURL(ctx) + "api/packages/" + url.PathEscape(owner.Name) + "/npm"
} }
// PackageMetadata returns the metadata for a single package func packageMetadata(ctx *context.Context) *npm_module.PackageMetadata {
func PackageMetadata(ctx *context.Context) { pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNpm, packageNameFromParams(ctx))
packageName := packageNameFromParams(ctx)
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNpm, packageName)
if err != nil { if err != nil {
apiError(ctx, http.StatusInternalServerError, err) apiError(ctx, http.StatusInternalServerError, err)
return return nil
} }
if len(pvs) == 0 { if len(pvs) == 0 {
apiError(ctx, http.StatusNotFound, err) apiError(ctx, http.StatusNotFound, err)
return return nil
} }
pds, err := packages_model.GetPackageDescriptors(ctx, pvs) pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
if err != nil { if err != nil {
apiError(ctx, http.StatusInternalServerError, err) apiError(ctx, http.StatusInternalServerError, err)
return return nil
} }
resp := createPackageMetadataResponse(buildNpmRegistryURL(ctx, ctx.Package.Owner), pds) return createPackageMetadataResponse(buildNpmRegistryURL(ctx, ctx.Package.Owner), pds)
ctx.JSON(http.StatusOK, resp) }
// PackageMetadata returns the metadata for a single package
func PackageMetadata(ctx *context.Context) {
if metadata := packageMetadata(ctx); metadata != nil {
serveMetadata(ctx, metadata)
}
}
func serveMetadata(ctx *context.Context, obj any) {
body, err := json.MarshalDeterministic(obj)
if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
}
ctx.Resp.Header().Set("ETag", fmt.Sprintf(`W/"%x"`, sha256.Sum256(body)))
ctx.ServeContent(bytes.NewReader(body), context.ServeHeaderOptions{ContentType: "application/json;charset=utf-8"})
} }
// PackageVersionMetadata returns the metadata for a single version or dist-tag // PackageVersionMetadata returns the metadata for a single version or dist-tag
@@ -100,7 +115,11 @@ func PackageVersionMetadata(ctx *context.Context) {
return return
} }
if len(pvs) == 0 { if len(pvs) == 0 {
apiError(ctx, http.StatusNotFound, "version not found: "+versionOrTag) if versionOrTag != "latest" {
apiError(ctx, http.StatusNotFound, "version not found: "+versionOrTag)
} else if metadata := packageMetadata(ctx); metadata != nil { // unset, so serve the packument's fallback
serveMetadata(ctx, metadata.Versions[metadata.DistTags["latest"]])
}
return return
} }
@@ -110,25 +129,40 @@ func PackageVersionMetadata(ctx *context.Context) {
return return
} }
ctx.JSON(http.StatusOK, createPackageMetadataVersion(buildNpmRegistryURL(ctx, ctx.Package.Owner), pd)) serveMetadata(ctx, createPackageMetadataVersion(buildNpmRegistryURL(ctx, ctx.Package.Owner), pd))
} }
// DownloadPackageFile serves the content of a package func packageVersionByFilename(ctx *context.Context) *packages_model.PackageVersion {
func DownloadPackageFile(ctx *context.Context) { pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{
packageName := packageNameFromParams(ctx) OwnerID: ctx.Package.Owner.ID,
packageVersion := ctx.PathParam("version") Type: packages_model.TypeNpm,
filename := ctx.PathParam("filename") Name: packages_model.SearchValue{ExactMatch: true, Value: packageNameFromParams(ctx)},
HasFileWithName: ctx.PathParam("filename"),
IsInternal: optional.Some(false),
})
if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return nil
}
if len(pvs) != 1 {
apiError(ctx, http.StatusNotFound, nil)
return nil
}
return pvs[0]
}
s, u, pf, err := packages_service.OpenFileForDownloadByPackageNameAndVersion( // DownloadPackageFileByName finds the version and serves the contents of a package
func DownloadPackageFileByName(ctx *context.Context) {
pv := packageVersionByFilename(ctx)
if pv == nil {
return
}
s, u, pf, err := packages_service.OpenFileForDownloadByPackageVersion(
ctx, ctx,
&packages_service.PackageInfo{ pv,
Owner: ctx.Package.Owner,
PackageType: packages_model.TypeNpm,
Name: packageName,
Version: packageVersion,
},
&packages_service.PackageFileInfo{ &packages_service.PackageFileInfo{
Filename: filename, Filename: ctx.PathParam("filename"),
}, },
ctx.Req.Method, ctx.Req.Method,
) )
@@ -140,54 +174,14 @@ func DownloadPackageFile(ctx *context.Context) {
helper.ServePackageFile(ctx, s, u, pf) helper.ServePackageFile(ctx, s, u, pf)
} }
// DownloadPackageFileByName finds the version and serves the contents of a package
func DownloadPackageFileByName(ctx *context.Context) {
filename := ctx.PathParam("filename")
pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{
OwnerID: ctx.Package.Owner.ID,
Type: packages_model.TypeNpm,
Name: packages_model.SearchValue{
ExactMatch: true,
Value: packageNameFromParams(ctx),
},
HasFileWithName: filename,
IsInternal: optional.Some(false),
})
if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
}
if len(pvs) != 1 {
apiError(ctx, http.StatusNotFound, nil)
return
}
s, u, pf, err := packages_service.OpenFileForDownloadByPackageVersion(
ctx,
pvs[0],
&packages_service.PackageFileInfo{
Filename: filename,
},
ctx.Req.Method,
)
if err != nil {
if errors.Is(err, packages_model.ErrPackageFileNotExist) {
apiError(ctx, http.StatusNotFound, err)
return
}
apiError(ctx, http.StatusInternalServerError, err)
return
}
helper.ServePackageFile(ctx, s, u, pf)
}
// UploadPackage creates a new package // UploadPackage creates a new package
func UploadPackage(ctx *context.Context) { func UploadPackage(ctx *context.Context) {
npmPackage, deprecation, err := npm_module.ParseUpload(ctx.Req.Body) // about the npmjs and GitHub Packages limit, fits base64 tarballs up to ~200 MB
npmPackage, deprecation, err := npm_module.ParseUpload(http.MaxBytesReader(ctx.Resp, ctx.Req.Body, 256*1024*1024))
if err != nil { if err != nil {
if errors.Is(err, util.ErrInvalidArgument) { if _, ok := errors.AsType[*http.MaxBytesError](err); ok {
apiError(ctx, http.StatusRequestEntityTooLarge, err)
} else if errors.Is(err, util.ErrInvalidArgument) {
apiError(ctx, http.StatusBadRequest, err) apiError(ctx, http.StatusBadRequest, err)
} else { } else {
apiError(ctx, http.StatusInternalServerError, err) apiError(ctx, http.StatusInternalServerError, err)
@@ -340,26 +334,14 @@ func deprecatePackage(ctx *context.Context, dep *npm_module.PackageDeprecation)
ctx.Status(http.StatusOK) ctx.Status(http.StatusOK)
} }
// DeletePackageVersion deletes the package version // DeletePackageVersion deletes the package version, which `npm unpublish` addresses by its tarball
func DeletePackageVersion(ctx *context.Context) { func DeletePackageVersion(ctx *context.Context) {
packageName := packageNameFromParams(ctx) pv := packageVersionByFilename(ctx)
packageVersion := ctx.PathParam("version") if pv == nil {
return
}
err := packages_service.RemovePackageVersionByNameAndVersion( if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pv); err != nil {
ctx,
ctx.Doer,
&packages_service.PackageInfo{
Owner: ctx.Package.Owner,
PackageType: packages_model.TypeNpm,
Name: packageName,
Version: packageVersion,
},
)
if err != nil {
if errors.Is(err, packages_model.ErrPackageNotExist) {
apiError(ctx, http.StatusNotFound, err)
return
}
apiError(ctx, http.StatusInternalServerError, err) apiError(ctx, http.StatusInternalServerError, err)
return return
} }
@@ -394,9 +376,7 @@ func DeletePackage(ctx *context.Context) {
// ListPackageTags returns all tags for a package // ListPackageTags returns all tags for a package
func ListPackageTags(ctx *context.Context) { func ListPackageTags(ctx *context.Context) {
packageName := packageNameFromParams(ctx) pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNpm, packageNameFromParams(ctx))
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNpm, packageName)
if err != nil { if err != nil {
apiError(ctx, http.StatusInternalServerError, err) apiError(ctx, http.StatusInternalServerError, err)
return return
@@ -414,7 +394,11 @@ func ListPackageTags(ctx *context.Context) {
} }
} }
ctx.JSON(http.StatusOK, tags) if _, ok := tags["latest"]; ok {
ctx.JSON(http.StatusOK, tags)
} else if metadata := packageMetadata(ctx); metadata != nil { // unset, so list the packument's fallback
ctx.JSON(http.StatusOK, metadata.DistTags)
}
} }
// AddPackageTag adds a tag to the package // AddPackageTag adds a tag to the package
@@ -524,6 +508,18 @@ func setPackageTag(ctx std_ctx.Context, tag string, pv *packages_model.PackageVe
}) })
} }
func Ping(ctx *context.Context) {
ctx.JSON(http.StatusOK, map[string]any{})
}
func Whoami(ctx *context.Context) {
if ctx.Doer == nil {
apiError(ctx, http.StatusUnauthorized, "Unauthorized")
return
}
ctx.JSON(http.StatusOK, map[string]string{"username": ctx.Doer.Name})
}
func PackageSearch(ctx *context.Context) { func PackageSearch(ctx *context.Context) {
pvs, total, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{ pvs, total, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{
OwnerID: ctx.Package.Owner.ID, OwnerID: ctx.Package.Owner.ID,
+2 -2
View File
@@ -400,7 +400,7 @@ func SearchUsers(ctx *context.APIContext) {
// parameters: // parameters:
// - name: source_id // - name: source_id
// in: query // in: query
// description: ID of the user's login source to search for, 0 means the local users // description: ID of the user's login source to search for
// type: integer // type: integer
// format: int64 // format: int64
// - name: login_name // - name: login_name
@@ -483,7 +483,7 @@ func SearchUsers(ctx *context.APIContext) {
Actor: ctx.Doer, Actor: ctx.Doer,
Types: []user_model.UserType{user_model.UserTypeIndividual}, Types: []user_model.UserType{user_model.UserTypeIndividual},
LoginName: ctx.FormTrim("login_name"), LoginName: ctx.FormTrim("login_name"),
SourceID: ctx.FormOptionalInt64("source_id"), SourceID: ctx.FormInt64("source_id"),
Keyword: ctx.FormTrim("q"), Keyword: ctx.FormTrim("q"),
Visible: visible, Visible: visible,
OrderBy: orderBy, OrderBy: orderBy,
+18 -34
View File
@@ -76,6 +76,7 @@ import (
repo_model "gitea.dev/models/repo" repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit" "gitea.dev/models/unit"
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
"gitea.dev/modules/httplib"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
api "gitea.dev/modules/structs" api "gitea.dev/modules/structs"
@@ -935,31 +936,22 @@ func apiAuth(authMethod auth.Method) func(*context.APIContext) {
} }
} }
// verifyAuthWithOptions checks authentication according to options // verifyAuthWithOptionsAPI checks authentication according to options
func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.APIContext) { func verifyAuthWithOptionsAPI(options *common.VerifyOptions) func(ctx *context.APIContext) {
return func(ctx *context.APIContext) { return func(ctx *context.APIContext) {
// Check prohibit login users. // Check prohibit login users.
if ctx.IsSigned { if ctx.IsSigned {
if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm { check := common.CheckSignedInUser(ctx.Doer, nil)
ctx.Data["Title"] = ctx.Tr("auth.active_your_account") if check.NeedActivateAccount {
ctx.JSON(http.StatusForbidden, map[string]string{ ctx.JSON(http.StatusForbidden, map[string]string{"message": "This account is not activated."})
"message": "This account is not activated.",
})
return return
} } else if check.LoginIsProhibited {
if !ctx.Doer.IsActive || ctx.Doer.ProhibitLogin {
log.Info("Failed authentication attempt for %s from %s", ctx.Doer.Name, ctx.RemoteAddr()) log.Info("Failed authentication attempt for %s from %s", ctx.Doer.Name, ctx.RemoteAddr())
ctx.Data["Title"] = ctx.Tr("auth.prohibit_login") ctx.JSON(http.StatusForbidden, map[string]string{"message": "This account is prohibited from signing in, please contact your site administrator."})
ctx.JSON(http.StatusForbidden, map[string]string{
"message": "This account is prohibited from signing in, please contact your site administrator.",
})
return return
} } else if check.NeedChangePassword {
msg := "You must change your password. Change it at: " + httplib.MakeAbsoluteURL(ctx, setting.AppSubURL+"/user/settings/change_password")
if ctx.Doer.MustChangePassword { ctx.JSON(http.StatusForbidden, map[string]string{"message": msg})
ctx.JSON(http.StatusForbidden, map[string]string{
"message": "You must change your password. Change it at: " + setting.AppURL + "/user/change_password",
})
return return
} }
} }
@@ -970,20 +962,12 @@ func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.APIC
return return
} }
if options.SignInRequired { if options.SignInRequired && !ctx.IsSigned {
if !ctx.IsSigned { // Restrict API calls with error message.
// Restrict API calls with error message. ctx.JSON(http.StatusForbidden, map[string]string{
ctx.JSON(http.StatusForbidden, map[string]string{ "message": "Only signed in user is allowed to call APIs.",
"message": "Only signed in user is allowed to call APIs.", })
}) return
return
} else if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm {
ctx.Data["Title"] = ctx.Tr("auth.active_your_account")
ctx.JSON(http.StatusForbidden, map[string]string{
"message": "This account is not activated.",
})
return
}
} }
if options.AdminRequired { if options.AdminRequired {
@@ -1035,7 +1019,7 @@ func Routes() *web.Router {
// Get user from session if logged in. // Get user from session if logged in.
m.AfterRouting(apiAuth(buildAuthGroup())) m.AfterRouting(apiAuth(buildAuthGroup()))
m.AfterRouting(verifyAuthWithOptions(&common.VerifyOptions{ m.AfterRouting(verifyAuthWithOptionsAPI(&common.VerifyOptions{
SignInRequired: setting.Service.RequireSignInViewStrict, SignInRequired: setting.Service.RequireSignInViewStrict,
})) }))
+1
View File
@@ -167,6 +167,7 @@ func assignTeamPermissionUnits(team *organization.Team, permission string, units
oldAccessMode := team.AccessMode oldAccessMode := team.AccessMode
oldUnitPerms := team.GetUnitsMap() oldUnitPerms := team.GetUnitsMap()
if len(unitsMap) > 0 { if len(unitsMap) > 0 {
team.AccessMode = perm.AccessModeNone
team.Units = make([]*organization.TeamUnit, 0, len(unitsMap)) team.Units = make([]*organization.TeamUnit, 0, len(unitsMap))
for unitKey, p := range unitsMap { for unitKey, p := range unitsMap {
unitType, unitPerm := unit_model.TypeFromKey(unitKey), perm.ParseAccessMode(p) unitType, unitPerm := unit_model.TypeFromKey(unitKey), perm.ParseAccessMode(p)
+1 -1
View File
@@ -1032,7 +1032,7 @@ func MergePullRequest(ctx *context.APIContext) {
} }
} }
if err := pull_service.Merge(pr.ID, ctx.Doer, repo_model.MergeStyle(form.Do), form.HeadCommitID, message, false); err != nil { if err := pull_service.Merge(ctx, pr.ID, ctx.Doer, repo_model.MergeStyle(form.Do), form.HeadCommitID, message, false); err != nil {
if pull_service.IsErrInvalidMergeStyle(err) { if pull_service.IsErrInvalidMergeStyle(err) {
ctx.APIError(http.StatusMethodNotAllowed, fmt.Sprintf("%s is not allowed an allowed merge style for this repository", repo_model.MergeStyle(form.Do))) ctx.APIError(http.StatusMethodNotAllowed, fmt.Sprintf("%s is not allowed an allowed merge style for this repository", repo_model.MergeStyle(form.Do)))
} else if conflictError, ok := err.(pull_service.ErrMergeConflicts); ok { } else if conflictError, ok := err.(pull_service.ErrMergeConflicts); ok {
+15
View File
@@ -6,6 +6,8 @@ package common
import ( import (
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/session"
"gitea.dev/modules/setting"
"gitea.dev/modules/web/middleware" "gitea.dev/modules/web/middleware"
auth_service "gitea.dev/services/auth" auth_service "gitea.dev/services/auth"
"gitea.dev/services/context" "gitea.dev/services/context"
@@ -56,3 +58,16 @@ type VerifyOptions struct {
AdminRequired bool AdminRequired bool
DisableCrossOriginProtection bool DisableCrossOriginProtection bool
} }
func CheckSignedInUser(doer *user_model.User, sess session.Store) (ret struct {
NeedActivateAccount bool
LoginIsProhibited bool
NeedChangePassword bool
},
) {
ret.NeedActivateAccount = !doer.IsActive && setting.Service.RegisterEmailConfirm
ret.LoginIsProhibited = !doer.IsActive || doer.ProhibitLogin
isImpersonated := sess != nil && context.IsDoerSessionImpersonated(sess)
ret.NeedChangePassword = doer.MustChangePassword && !isImpersonated && !doer.IsTypeBot()
return ret
}
+53
View File
@@ -0,0 +1,53 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import (
"testing"
user_model "gitea.dev/models/user"
"gitea.dev/modules/session"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"github.com/stretchr/testify/assert"
)
func TestCheckSignedInUser(t *testing.T) {
defer test.MockVariableValue(&setting.Service.RegisterEmailConfirm)()
sessNormal := session.NewMockMemStore("session-a")
sessImpersonated := session.NewMockMemStore("session-b")
_ = sessImpersonated.Set(session.KeyImpersonatorData, "any-value")
setting.Service.RegisterEmailConfirm = false
ret := CheckSignedInUser(&user_model.User{IsActive: false}, nil)
assert.False(t, ret.NeedActivateAccount)
assert.True(t, ret.LoginIsProhibited)
setting.Service.RegisterEmailConfirm = true
ret = CheckSignedInUser(&user_model.User{IsActive: false}, nil)
assert.True(t, ret.NeedActivateAccount)
assert.True(t, ret.LoginIsProhibited)
ret = CheckSignedInUser(&user_model.User{IsActive: true}, nil)
assert.False(t, ret.NeedActivateAccount)
assert.False(t, ret.LoginIsProhibited)
assert.False(t, ret.NeedChangePassword)
ret = CheckSignedInUser(&user_model.User{IsActive: true, ProhibitLogin: true}, nil)
assert.False(t, ret.NeedActivateAccount)
assert.True(t, ret.LoginIsProhibited)
ret = CheckSignedInUser(&user_model.User{MustChangePassword: true}, nil)
assert.True(t, ret.NeedChangePassword)
ret = CheckSignedInUser(&user_model.User{MustChangePassword: true}, sessNormal)
assert.True(t, ret.NeedChangePassword)
ret = CheckSignedInUser(&user_model.User{MustChangePassword: true, Type: user_model.UserTypeBot}, sessNormal)
assert.False(t, ret.NeedChangePassword)
ret = CheckSignedInUser(&user_model.User{MustChangePassword: true}, sessImpersonated)
assert.False(t, ret.NeedChangePassword)
}
+2 -2
View File
@@ -229,9 +229,9 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
} }
} else { } else {
if isForcePush { if isForcePush {
canPush = !changedProtectedfiles && protectBranch.CanUserForcePush(ctx, ctx.Doer) canPush = !changedProtectedfiles && protectBranch.CanUserForcePush(ctx, ctx.Doer, ctx.Repo.Permission)
} else { } else {
canPush = !changedProtectedfiles && protectBranch.CanUserPush(ctx, ctx.Doer) canPush = !changedProtectedfiles && protectBranch.CanUserPush(ctx, ctx.Doer, ctx.Repo.Permission)
} }
} }
+51
View File
@@ -6,16 +6,67 @@ package private
import ( import (
"testing" "testing"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
issues_model "gitea.dev/models/issues" issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo" repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest" "gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/git" "gitea.dev/modules/git"
"gitea.dev/modules/private"
"gitea.dev/services/contexttest" "gitea.dev/services/contexttest"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestPreReceiveActionsProtectedBranch(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
for _, tc := range []struct {
name string
protection git_model.ProtectedBranch
forcePush bool
allowed bool
}{
{name: "push", protection: git_model.ProtectedBranch{CanPush: true}, allowed: true},
{name: "push allowlist", protection: git_model.ProtectedBranch{CanPush: true, EnableWhitelist: true}},
{name: "force push", protection: git_model.ProtectedBranch{CanPush: true, CanForcePush: true}, forcePush: true, allowed: true},
{name: "force push allowlist", protection: git_model.ProtectedBranch{CanPush: true, CanForcePush: true, EnableForcePushAllowlist: true}, forcePush: true},
} {
t.Run(tc.name, func(t *testing.T) {
mockCtx, resp := contexttest.MockPrivateContext(t, "/")
ctx := &preReceiveContext{PrivateContext: mockCtx, opts: &private.HookOptions{UserID: user_model.ActionsUserID}}
ctx.SetPathParam("owner", "user2")
ctx.SetPathParam("repo", "repo2")
RepoAssignment(ctx.PrivateContext)
require.False(t, ctx.Written())
defer ctx.Repo.GitRepo.Close()
doer := user_model.NewActionsUserWithTaskID(53)
loadContextDoerPermission(ctx.PrivateContext, doer.ID, doer.ExtDoerData.EncodeToString())
protection := tc.protection
protection.RepoID = ctx.Repo.Repository.ID
protection.RuleName = "probe"
require.NoError(t, db.Insert(t.Context(), &protection))
defer func() {
require.NoError(t, git_model.DeleteProtectedBranch(t.Context(), ctx.Repo.Repository, protection.ID))
}()
oldCommitID, newCommitID := "205ac761f3326a7ebe416e8673760016450b5cec", "1032bbf17fbc0d9c95bb5418dabe8f8c99278700"
if tc.forcePush {
oldCommitID, newCommitID = newCommitID, oldCommitID
}
preReceiveBranch(ctx, oldCommitID, newCommitID, git.RefNameFromBranch("probe"))
if tc.allowed {
assert.False(t, ctx.Written(), resp.Body.String())
} else {
assert.Contains(t, resp.Body.String(), "Not allowed to")
}
})
}
}
// TestPreReceiveCanWriteCodePerBranch ensures the maintainer-edit write grant is evaluated against // TestPreReceiveCanWriteCodePerBranch ensures the maintainer-edit write grant is evaluated against
// the exact ref being pushed on every call, derived from that ref rather than shared mutable state. // the exact ref being pushed on every call, derived from that ref rather than shared mutable state.
// Otherwise, a per-branch grant (an open PR with "allow edits from maintainers") could be batched // Otherwise, a per-branch grant (an open PR with "allow edits from maintainers") could be batched
-32
View File
@@ -48,13 +48,6 @@ const (
// UserSearchDefaultAdminSort is the default sort type for admin view // UserSearchDefaultAdminSort is the default sort type for admin view
const UserSearchDefaultAdminSort = "alphabetically" const UserSearchDefaultAdminSort = "alphabetically"
// authSourceFilterOption is one radio item of the authentication source filter dropdown
type authSourceFilterOption struct {
Value string
Label string
Selected bool
}
// Users show all the users // Users show all the users
func Users(ctx *context.Context) { func Users(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.users") ctx.Data["Title"] = ctx.Tr("admin.users")
@@ -83,30 +76,6 @@ func Users(ctx *context.Context) {
"SortType": sortType, "SortType": sortType,
} }
// inactive sources are listed too, users stay attached to a source after it is deactivated
sources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{})
if err != nil {
ctx.ServerError("auth.Sources", err)
return
}
sourceIDFilter := ctx.FormOptionalInt64("source_id")
sourceNames := make(map[int64]string, len(sources))
authSourceFilterOptions := []*authSourceFilterOption{
{Value: "", Label: ctx.Locale.TrString("all"), Selected: !sourceIDFilter.Has()},
{Value: "0", Label: ctx.Locale.TrString("admin.users.local"), Selected: sourceIDFilter.Has() && sourceIDFilter.Value() == 0},
}
for _, source := range sources {
sourceNames[source.ID] = source.Name
authSourceFilterOptions = append(authSourceFilterOptions, &authSourceFilterOption{
Value: strconv.FormatInt(source.ID, 10),
Label: source.Name,
Selected: sourceIDFilter.Has() && sourceIDFilter.Value() == source.ID,
})
}
ctx.Data["HasAuthSources"] = len(sources) > 0
ctx.Data["SourceNames"] = sourceNames
ctx.Data["AuthSourceFilterOptions"] = authSourceFilterOptions
explore.RenderUserSearch(ctx, user_model.SearchUserOptions{ explore.RenderUserSearch(ctx, user_model.SearchUserOptions{
Actor: ctx.Doer, Actor: ctx.Doer,
Types: types, Types: types,
@@ -119,7 +88,6 @@ func Users(ctx *context.Context) {
IsRestricted: optional.ParseBool(statusFilterMap["is_restricted"]), IsRestricted: optional.ParseBool(statusFilterMap["is_restricted"]),
IsTwoFactorEnabled: optional.ParseBool(statusFilterMap["is_2fa_enabled"]), IsTwoFactorEnabled: optional.ParseBool(statusFilterMap["is_2fa_enabled"]),
IsProhibitLogin: optional.ParseBool(statusFilterMap["is_prohibit_login"]), IsProhibitLogin: optional.ParseBool(statusFilterMap["is_prohibit_login"]),
SourceID: sourceIDFilter,
OrderBy: db.SearchOrderBy(sortType), OrderBy: db.SearchOrderBy(sortType),
}, tplUsers) }, tplUsers)
} }
+1 -1
View File
@@ -374,7 +374,7 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
// Reactivate user only if they were disabled by the OAuth2 auto sync cron (invalid_grant), // Reactivate user only if they were disabled by the OAuth2 auto sync cron (invalid_grant),
// which clears AccessToken/RefreshToken/ExpiresAt on the ExternalLoginUser row // which clears AccessToken/RefreshToken/ExpiresAt on the ExternalLoginUser row
// An admin-disabled user has no such signature, so we leave IsActive alone // An admin-disabled user has no such signature, so we leave IsActive alone
// and let verifyAuthWithOptions route them through the prohibit-login / activate page. // and let verifyAuthWithOptionsWeb route them through the prohibit-login / activate page.
if !u.IsActive { if !u.IsActive {
extLogin, hasExt, err := user_model.GetExternalLogin(ctx, authSource.ID, gothUser.UserID) extLogin, hasExt, err := user_model.GetExternalLogin(ctx, authSource.ID, gothUser.UserID)
if err != nil { if err != nil {
+17 -7
View File
@@ -11,6 +11,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
"strings"
audit_model "gitea.dev/models/audit" audit_model "gitea.dev/models/audit"
"gitea.dev/models/auth" "gitea.dev/models/auth"
@@ -20,6 +21,7 @@ import (
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
"gitea.dev/modules/templates" "gitea.dev/modules/templates"
"gitea.dev/modules/util"
"gitea.dev/modules/web" "gitea.dev/modules/web"
"gitea.dev/services/audit" "gitea.dev/services/audit"
auth_service "gitea.dev/services/auth" auth_service "gitea.dev/services/auth"
@@ -321,9 +323,18 @@ func AuthorizeOAuth(ctx *context.Context) {
return return
} }
var addedScopes, removedScopes []string
if grant != nil {
if form.Scope == "" {
form.Scope = grant.Scope
}
addedScopes, removedScopes = util.DiffSlice(strings.Fields(grant.Scope), strings.Fields(form.Scope))
}
scopeChanged := len(addedScopes) > 0 || len(removedScopes) > 0
// Redirect if user already granted access and the application is confidential or trusted otherwise // Redirect if user already granted access and the application is confidential or trusted otherwise
// I.e. always require authorization for untrusted public clients as recommended by RFC 6749 Section 10.2 // I.e. always require authorization for untrusted public clients as recommended by RFC 6749 Section 10.2
if (app.ConfidentialClient || app.SkipSecondaryAuthorization) && grant != nil { if (app.ConfidentialClient || app.SkipSecondaryAuthorization) && grant != nil && !scopeChanged {
code, err := grant.GenerateNewAuthorizationCode(ctx, form.RedirectURI, form.CodeChallenge, form.CodeChallengeMethod) code, err := grant.GenerateNewAuthorizationCode(ctx, form.RedirectURI, form.CodeChallenge, form.CodeChallengeMethod)
if err != nil { if err != nil {
handleServerError(ctx, form.State, form.RedirectURI) handleServerError(ctx, form.State, form.RedirectURI)
@@ -347,6 +358,7 @@ func AuthorizeOAuth(ctx *context.Context) {
// check if additional scopes // check if additional scopes
ctx.Data["AdditionalScopes"] = oauth2_provider.GrantAdditionalScopes(form.Scope) != auth.AccessTokenScopeAll ctx.Data["AdditionalScopes"] = oauth2_provider.GrantAdditionalScopes(form.Scope) != auth.AccessTokenScopeAll
ctx.Data["AddedScopes"] = addedScopes
// show authorize page to grant access // show authorize page to grant access
ctx.Data["Application"] = app ctx.Data["Application"] = app
@@ -432,12 +444,10 @@ func GrantApplicationOAuth(ctx *context.Context) {
audit.Record(ctx, audit_model.UserOAuth2ApplicationGrant, ctx.Doer, "oauth2_application", app.Name, "granted_scope", form.Scope) audit.Record(ctx, audit_model.UserOAuth2ApplicationGrant, ctx.Doer, "oauth2_application", app.Name, "granted_scope", form.Scope)
} else if grant.Scope != form.Scope { } else if grant.Scope != form.Scope {
handleAuthorizeError(ctx, AuthorizeError{ if err := auth.UpdateGrantScope(ctx, grant, form.Scope); err != nil {
State: form.State, handleServerError(ctx, form.State, form.RedirectURI)
ErrorDescription: "a grant exists with different scope", return
ErrorCode: ErrorCodeServerError, }
}, form.RedirectURI)
return
} }
if len(form.Nonce) > 0 { if len(form.Nonce) > 0 {
+28
View File
@@ -13,6 +13,10 @@ import (
"gitea.dev/models/unittest" "gitea.dev/models/unittest"
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
"gitea.dev/modules/egress/policy" "gitea.dev/modules/egress/policy"
"gitea.dev/modules/session"
"gitea.dev/modules/web"
"gitea.dev/services/contexttest"
"gitea.dev/services/forms"
"gitea.dev/services/oauth2_provider" "gitea.dev/services/oauth2_provider"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
@@ -105,3 +109,27 @@ func TestOAuth2AvatarClientBlocksCloudMetadata(t *testing.T) {
assert.ErrorIs(t, err, policy.ErrDenied, assert.ErrorIs(t, err, policy.ErrDenied,
"avatar client must refuse a link-local cloud-metadata address") "avatar client must refuse a link-local cloud-metadata address")
} }
func TestOAuth2ScopeChange(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
app := unittest.AssertExistsAndLoadBean(t, &auth.OAuth2Application{ID: 1})
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
mockOpt := contexttest.MockContextOption{SessionStore: session.NewMockMemStore("oauth2-scope-change")}
authorize := func(scope string) int {
ctx, resp := contexttest.MockContext(t, "/login/oauth/authorize", mockOpt)
ctx.Doer = doer
web.SetForm(ctx, &forms.AuthorizationForm{ResponseType: "code", ClientID: app.ClientID, RedirectURI: app.RedirectURIs[0], State: "state", Scope: scope})
AuthorizeOAuth(ctx)
return resp.Code
}
assert.Equal(t, http.StatusSeeOther, authorize(""))
assert.Equal(t, http.StatusSeeOther, authorize("profile openid"))
assert.Equal(t, http.StatusOK, authorize("openid profile email"))
ctx, resp := contexttest.MockContext(t, "/login/oauth/grant", mockOpt)
ctx.Doer = doer
web.SetForm(ctx, &forms.GrantApplicationForm{ClientID: app.ClientID, Granted: true, RedirectURI: app.RedirectURIs[0], State: "state", Scope: "openid profile email"})
GrantApplicationOAuth(ctx)
assert.Equal(t, http.StatusSeeOther, resp.Code)
unittest.AssertExistsAndLoadBean(t, &auth.OAuth2Grant{ID: 1, Scope: "openid profile email"})
}
+4 -4
View File
@@ -17,6 +17,7 @@ import (
"gitea.dev/modules/templates" "gitea.dev/modules/templates"
"gitea.dev/modules/timeutil" "gitea.dev/modules/timeutil"
"gitea.dev/modules/web" "gitea.dev/modules/web"
"gitea.dev/routers/common"
"gitea.dev/services/audit" "gitea.dev/services/audit"
"gitea.dev/services/context" "gitea.dev/services/context"
"gitea.dev/services/forms" "gitea.dev/services/forms"
@@ -282,10 +283,9 @@ func MustChangePasswordPost(ctx *context.Context) {
return return
} }
// Make sure only requests for users who are eligible to change their password via if !common.CheckSignedInUser(ctx.Doer, ctx.Session).NeedChangePassword {
// this method passes through log.Debug("User %s attempted to access the must change password page, but they are not required to change their password", ctx.Doer.Name)
if !ctx.Doer.MustChangePassword { ctx.NotFound(nil)
ctx.ServerError("MustUpdatePassword", errors.New("cannot update password. Please visit the settings page"))
return return
} }
+3 -23
View File
@@ -17,38 +17,18 @@ import (
"gitea.dev/modules/sitemap" "gitea.dev/modules/sitemap"
"gitea.dev/modules/structs" "gitea.dev/modules/structs"
"gitea.dev/modules/templates" "gitea.dev/modules/templates"
"gitea.dev/modules/web/middleware"
"gitea.dev/routers/web/auth"
"gitea.dev/routers/web/user" "gitea.dev/routers/web/user"
"gitea.dev/services/context" "gitea.dev/services/context"
) )
const ( const tplHome templates.TplName = "home"
// tplHome home page template
tplHome templates.TplName = "home"
)
// Home render home page
func Home(ctx *context.Context) { func Home(ctx *context.Context) {
if ctx.IsSigned { if ctx.IsSigned {
if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm { user.Dashboard(ctx)
ctx.Data["Title"] = ctx.Tr("auth.active_your_account")
ctx.HTML(http.StatusOK, auth.TplActivate)
} else if !ctx.Doer.IsActive || ctx.Doer.ProhibitLogin {
log.Info("Failed authentication attempt for %s from %s", ctx.Doer.Name, ctx.RemoteAddr())
ctx.Data["Title"] = ctx.Tr("auth.prohibit_login")
ctx.HTML(http.StatusOK, "user/auth/prohibit_login")
} else if doerMustChangePassword(ctx) {
ctx.Data["Title"] = ctx.Tr("auth.must_change_password")
ctx.Data["ChangePasscodeLink"] = setting.AppSubURL + "/user/change_password"
middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI())
ctx.Redirect(setting.AppSubURL + "/user/settings/change_password")
} else {
user.Dashboard(ctx)
}
return return
// Check non-logged users landing page.
} else if setting.LandingPageURL != setting.LandingPageHome { } else if setting.LandingPageURL != setting.LandingPageHome {
// Check non-logged users landing page
ctx.Redirect(setting.AppSubURL + string(setting.LandingPageURL)) ctx.Redirect(setting.AppSubURL + string(setting.LandingPageURL))
return return
} }
+2 -15
View File
@@ -17,7 +17,6 @@ import (
"gitea.dev/modules/git" "gitea.dev/modules/git"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/markup/markdown" "gitea.dev/modules/markup/markdown"
repo_module "gitea.dev/modules/repository"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
api "gitea.dev/modules/structs" api "gitea.dev/modules/structs"
"gitea.dev/modules/util" "gitea.dev/modules/util"
@@ -137,21 +136,9 @@ func NewComment(ctx *context.Context) {
return return
} }
err = pull.LoadIssue(ctx)
if err != nil {
ctx.ServerError("load the issue of pull request error", err)
return
}
if prHeadCommitID != headBranchCommitID { if prHeadCommitID != headBranchCommitID {
// force push to base repo if err := pull_service.PushToBaseRepo(ctx, pull); err != nil {
err := git.PushManaged(ctx, pull.HeadRepo, pull.BaseRepo, git.PushOptions{ ctx.ServerError("PushToBaseRepo", err)
Branch: pull.HeadBranch + ":" + prHeadRef,
Force: true,
Env: repo_module.InternalPushingEnvironment(pull.Issue.Poster, pull.BaseRepo),
})
if err != nil {
ctx.ServerError("force push error", err)
return return
} }
} }
+30 -28
View File
@@ -4,8 +4,6 @@
package repo package repo
import ( import (
"bytes"
"html"
"html/template" "html/template"
"net/http" "net/http"
"strings" "strings"
@@ -15,6 +13,7 @@ import (
"gitea.dev/modules/htmlutil" "gitea.dev/modules/htmlutil"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/templates" "gitea.dev/modules/templates"
"gitea.dev/modules/util"
"gitea.dev/services/context" "gitea.dev/services/context"
"github.com/sergi/go-diff/diffmatchpatch" "github.com/sergi/go-diff/diffmatchpatch"
@@ -110,6 +109,34 @@ func canSoftDeleteContentHistory(ctx *context.Context, issue *issues_model.Issue
return canSoftDelete return canSoftDelete
} }
func diffContentHistory(oldContent, newContent string) template.HTML {
// compare the current history revision with the previous one
dmp := diffmatchpatch.New()
// `checklines=false` makes better diff result
diff := dmp.DiffMain(util.NormalizeStringEOL(oldContent), util.NormalizeStringEOL(newContent), false)
diff = dmp.DiffCleanupEfficiency(diff)
// use chroma to render the diff html
buf := &htmlutil.HTMLBuilder{}
buf.WriteHTML(`<pre class="chroma">`)
for _, it := range diff {
switch it.Type {
case diffmatchpatch.DiffInsert:
buf.WriteHTML(`<span class="gi">`)
buf.WriteString(it.Text)
buf.WriteHTML("</span>")
case diffmatchpatch.DiffDelete:
buf.WriteHTML(`<span class="gd">`)
buf.WriteString(it.Text)
buf.WriteHTML("</span>")
default:
buf.WriteString(it.Text)
}
}
buf.WriteHTML("</pre>")
return buf.HTMLString()
}
// GetContentHistoryDetail get detail // GetContentHistoryDetail get detail
func GetContentHistoryDetail(ctx *context.Context) { func GetContentHistoryDetail(ctx *context.Context) {
issue := GetActionIssue(ctx) issue := GetActionIssue(ctx)
@@ -144,36 +171,11 @@ func GetContentHistoryDetail(ctx *context.Context) {
prevHistoryContentText = prevHistory.ContentText prevHistoryContentText = prevHistory.ContentText
} }
// compare the current history revision with the previous one
dmp := diffmatchpatch.New()
// `checklines=false` makes better diff result
diff := dmp.DiffMain(prevHistoryContentText, history.ContentText, false)
diff = dmp.DiffCleanupEfficiency(diff)
// use chroma to render the diff html
diffHTMLBuf := bytes.Buffer{}
diffHTMLBuf.WriteString("<pre class='chroma'>")
for _, it := range diff {
switch it.Type {
case diffmatchpatch.DiffInsert:
diffHTMLBuf.WriteString("<span class='gi'>")
diffHTMLBuf.WriteString(html.EscapeString(it.Text))
diffHTMLBuf.WriteString("</span>")
case diffmatchpatch.DiffDelete:
diffHTMLBuf.WriteString("<span class='gd'>")
diffHTMLBuf.WriteString(html.EscapeString(it.Text))
diffHTMLBuf.WriteString("</span>")
default:
diffHTMLBuf.WriteString(html.EscapeString(it.Text))
}
}
diffHTMLBuf.WriteString("</pre>")
ctx.JSON(http.StatusOK, map[string]any{ ctx.JSON(http.StatusOK, map[string]any{
"canSoftDelete": canSoftDeleteContentHistory(ctx, issue, comment, history), "canSoftDelete": canSoftDeleteContentHistory(ctx, issue, comment, history),
"historyId": historyID, "historyId": historyID,
"prevHistoryId": prevHistoryID, "prevHistoryId": prevHistoryID,
"diffHtml": diffHTMLBuf.String(), "diffHtml": diffContentHistory(prevHistoryContentText, history.ContentText),
}) })
} }
@@ -0,0 +1,17 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestDiffContentHistory(t *testing.T) {
out := diffContentHistory("<\r\n&\r\n>", "<\nXXX\n>")
assert.Equal(t, `<pre class="chroma">&lt;
<span class="gd">&amp;</span><span class="gi">XXX</span>
&gt;</pre>`, string(out))
}
+1 -1
View File
@@ -1145,7 +1145,7 @@ func MergePullRequest(ctx *context.Context) {
} }
} }
if err := pull_service.Merge(pr.ID, ctx.Doer, repo_model.MergeStyle(form.Do), form.HeadCommitID, message, false); err != nil { if err := pull_service.Merge(ctx, pr.ID, ctx.Doer, repo_model.MergeStyle(form.Do), form.HeadCommitID, message, false); err != nil {
if pull_service.IsErrInvalidMergeStyle(err) { if pull_service.IsErrInvalidMergeStyle(err) {
ctx.JSONError(ctx.Tr("repo.pulls.invalid_merge_option")) ctx.JSONError(ctx.Tr("repo.pulls.invalid_merge_option"))
} else if conflictError, ok := err.(pull_service.ErrMergeConflicts); ok { } else if conflictError, ok := err.(pull_service.ErrMergeConflicts); ok {
+5 -1
View File
@@ -274,7 +274,11 @@ func prepareUserProfileTabData(ctx *context.Context, profileDbRepo *repo_model.R
ctx.ServerError("GetUserOrganizations", err) ctx.ServerError("GetUserOrganizations", err)
return return
} }
ctx.Data["Cards"] = orgs cards := make([]*user_model.User, 0, len(orgs))
for _, org := range orgs {
cards = append(cards, org.AsUser())
}
ctx.Data["Cards"] = cards // user_cards needs User-only methods
total = count total = count
default: // default to "repositories" default: // default to "repositories"
repos, count, err = repo_model.SearchRepository(ctx, repo_model.SearchRepoOptions{ repos, count, err = repo_model.SearchRepository(ctx, repo_model.SearchRepoOptions{
+18 -32
View File
@@ -174,38 +174,29 @@ func newWebAuthMiddleware() *AuthMiddleware {
return webAuth return webAuth
} }
func doerMustChangePassword(ctx *context.Context) bool { // verifyAuthWithOptionsWeb checks authentication according to options
// an impersonating admin must not be forced to set the impersonated user's password func verifyAuthWithOptionsWeb(options *common.VerifyOptions) func(ctx *context.Context) {
return ctx.Doer != nil && ctx.Doer.MustChangePassword && !ctx.DoerIsImpersonated()
}
// verifyAuthWithOptions checks authentication according to options
func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.Context) {
crossOriginProtection := http.NewCrossOriginProtection() crossOriginProtection := http.NewCrossOriginProtection()
return func(ctx *context.Context) { return func(ctx *context.Context) {
// Check prohibit login users. // Check prohibit login users.
if ctx.IsSigned { if ctx.IsSigned {
if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm { check := common.CheckSignedInUser(ctx.Doer, ctx.Session)
if check.NeedActivateAccount {
ctx.Data["Title"] = ctx.Tr("auth.active_your_account") ctx.Data["Title"] = ctx.Tr("auth.active_your_account")
ctx.HTML(http.StatusOK, "user/auth/activate") ctx.HTML(http.StatusOK, "user/auth/activate")
return return
} } else if check.LoginIsProhibited {
if !ctx.Doer.IsActive || ctx.Doer.ProhibitLogin {
log.Info("Failed authentication attempt for %s from %s", ctx.Doer.Name, ctx.RemoteAddr()) log.Info("Failed authentication attempt for %s from %s", ctx.Doer.Name, ctx.RemoteAddr())
ctx.Data["Title"] = ctx.Tr("auth.prohibit_login") ctx.Data["Title"] = ctx.Tr("auth.prohibit_login")
ctx.HTML(http.StatusOK, "user/auth/prohibit_login") ctx.HTML(http.StatusOK, "user/auth/prohibit_login")
return return
} } else if check.NeedChangePassword {
if doerMustChangePassword(ctx) {
if ctx.Req.URL.Path != "/user/settings/change_password" { if ctx.Req.URL.Path != "/user/settings/change_password" {
if strings.HasPrefix(ctx.Req.UserAgent(), "git") { if strings.HasPrefix(ctx.Req.UserAgent(), "git") {
ctx.HTTPError(http.StatusUnauthorized, ctx.Locale.TrString("auth.must_change_password")) ctx.HTTPError(http.StatusUnauthorized, ctx.Locale.TrString("auth.must_change_password"))
return return
} }
ctx.Data["Title"] = ctx.Tr("auth.must_change_password")
ctx.Data["ChangePasscodeLink"] = setting.AppSubURL + "/user/change_password"
middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI()) middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI())
ctx.Redirect(setting.AppSubURL + "/user/settings/change_password") ctx.Redirect(setting.AppSubURL + "/user/settings/change_password")
return return
@@ -230,15 +221,9 @@ func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.Cont
} }
} }
if options.SignInRequired { if options.SignInRequired && !ctx.IsSigned {
if !ctx.IsSigned { ctx.Redirect(middleware.RedirectLinkUserLogin(ctx.Req))
ctx.Redirect(middleware.RedirectLinkUserLogin(ctx.Req)) return
return
} else if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm {
ctx.Data["Title"] = ctx.Tr("auth.active_your_account")
ctx.HTML(http.StatusOK, "user/auth/activate")
return
}
} }
// Redirect to log in page if auto-signin info is provided and has not signed in. // Redirect to log in page if auto-signin info is provided and has not signed in.
@@ -336,7 +321,7 @@ func Routes() *web.Router {
// The CORS mechanism already protects cross-origin requests, and the CrossOriginProtection has no "allowed origin" list, so disable CrossOriginProtection. // The CORS mechanism already protects cross-origin requests, and the CrossOriginProtection has no "allowed origin" list, so disable CrossOriginProtection.
// - For non-browser client requests: git clone via http, no Sec-Fetch-Site header. // - For non-browser client requests: git clone via http, no Sec-Fetch-Site header.
// Such requests are not cross-origin requests, so disable CrossOriginProtection. // Such requests are not cross-origin requests, so disable CrossOriginProtection.
var optSignInFromAnyOrigin = verifyAuthWithOptions(&common.VerifyOptions{DisableCrossOriginProtection: true}) var optSignInFromAnyOrigin = verifyAuthWithOptionsWeb(&common.VerifyOptions{DisableCrossOriginProtection: true})
// addProjectBoardRoutes registers a board's column and card routes, shared by the // addProjectBoardRoutes registers a board's column and card routes, shared by the
// repository and owner mount points. // repository and owner mount points.
@@ -355,13 +340,14 @@ func addProjectBoardRoutes(m *web.Router) {
// registerWebRoutes register routes // registerWebRoutes register routes
func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
// middleware: required to be signed in or signed out // middleware: required to be signed in or signed out
reqSignIn := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: true}) reqSignIn := verifyAuthWithOptionsWeb(&common.VerifyOptions{SignInRequired: true})
reqSignOut := verifyAuthWithOptions(&common.VerifyOptions{SignOutRequired: true}) reqSignOut := verifyAuthWithOptionsWeb(&common.VerifyOptions{SignOutRequired: true})
// middleware: optional sign in (if signed in, use the user as doer, if not, no doer) // middleware: optional sign in (if signed in, use the user as doer, if not, no doer)
optSignIn := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: setting.Service.RequireSignInViewStrict}) optSignInHome := verifyAuthWithOptionsWeb(&common.VerifyOptions{SignInRequired: false}) // site home doesn't need "require sign-in" protection
optExploreSignIn := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: setting.Service.RequireSignInViewStrict || setting.Service.Explore.RequireSigninView}) optSignIn := verifyAuthWithOptionsWeb(&common.VerifyOptions{SignInRequired: setting.Service.RequireSignInViewStrict})
optExploreSignIn := verifyAuthWithOptionsWeb(&common.VerifyOptions{SignInRequired: setting.Service.RequireSignInViewStrict || setting.Service.Explore.RequireSigninView})
// middleware: only apply CrossOriginProtection // middleware: only apply CrossOriginProtection
crossOriginProtect := verifyAuthWithOptions(&common.VerifyOptions{DisableCrossOriginProtection: false}) crossOriginProtect := verifyAuthWithOptionsWeb(&common.VerifyOptions{DisableCrossOriginProtection: false})
openIDSignInEnabled := func(ctx *context.Context) { openIDSignInEnabled := func(ctx *context.Context) {
if !setting.Service.EnableOpenIDSignIn { if !setting.Service.EnableOpenIDSignIn {
@@ -533,7 +519,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
// FIXME: not all routes need go through same middleware. // FIXME: not all routes need go through same middleware.
// Especially some AJAX requests, we can reduce middleware number to improve performance. // Especially some AJAX requests, we can reduce middleware number to improve performance.
m.Get("/", Home) m.Get("/", optSignInHome, Home)
m.Get("/sitemap.xml", sitemapEnabled, optExploreSignIn, HomeSitemap) m.Get("/sitemap.xml", sitemapEnabled, optExploreSignIn, HomeSitemap)
m.Group("/.well-known", func() { m.Group("/.well-known", func() {
m.Get("/openid-configuration", auth.OIDCWellKnown) m.Get("/openid-configuration", auth.OIDCWellKnown)
@@ -780,7 +766,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Get("/avatar/{hash}", user.AvatarByEmailHash) m.Get("/avatar/{hash}", user.AvatarByEmailHash)
adminReq := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: true, AdminRequired: true}) adminReq := verifyAuthWithOptionsWeb(&common.VerifyOptions{SignInRequired: true, AdminRequired: true})
// ***** START: Admin ***** // ***** START: Admin *****
m.Group("/-/admin", func() { m.Group("/-/admin", func() {
+37 -2
View File
@@ -362,15 +362,50 @@ func checkJobsOfCurrentRunAttempt(ctx context.Context, run *actions_model.Action
} }
result.UpdatedJobs = append(result.UpdatedJobs, resolver.matrixUpdatedJobs...) result.UpdatedJobs = append(result.UpdatedJobs, resolver.matrixUpdatedJobs...)
// Caller and matrix expansion insert Pending or Blocked jobs and a deferred gate leaves a job Blocked, only a follow-up pass resolves them. hasFinishedCaller, err := reloadCallersFinishedByChildren(ctx, run.ID, resolver.jobMap, result.UpdatedJobs)
if err != nil {
return nil, err
}
// Only a follow-up pass resolves:
// - the children a caller expansion inserted, or the dependents of a caller that failed to expand
// - the siblings a matrix expansion inserted and the expanded job's dependents, or the dependents of a placeholder that failed to expand
// - a job the deferred gate left Blocked
// - the dependents of a caller finished by its children, which was still unfinished to the resolver
// Like the caller's children, matrix siblings are left out of result.Jobs and picked up there. // Like the caller's children, matrix siblings are left out of result.Jobs and picked up there.
if expandedAnyCaller || resolver.matrixChanged || resolver.gateDeferred { if expandedAnyCaller || resolver.matrixChanged || resolver.gateDeferred || hasFinishedCaller {
result.RunIDsToReEmit = append(result.RunIDsToReEmit, run.ID) result.RunIDsToReEmit = append(result.RunIDsToReEmit, run.ID)
} }
result.CancelledJobs = append(result.CancelledJobs, resolver.cancelledJobs...) result.CancelledJobs = append(result.CancelledJobs, resolver.cancelledJobs...)
return result, nil return result, nil
} }
// reloadCallersFinishedByChildren reloads the callers the children's cascade finished in this pass,
// so this pass's commit statuses and run notification see the callers finished.
// They are kept out of result.UpdatedJobs, so no workflow_job webhook is sent for them, as for callers finished through a runner.
func reloadCallersFinishedByChildren(ctx context.Context, runID int64, jobMap map[int64]*actions_model.ActionRunJob, updatedJobs []*actions_model.ActionRunJob) (bool, error) {
hasFinishedCaller := false
checkedCallers := make(container.Set[int64])
finishedJobs := slices.Clone(updatedJobs)
for i := 0; i < len(finishedJobs); i++ {
child := finishedJobs[i]
caller := jobMap[child.ParentJobID]
if caller == nil || !child.Status.IsDone() || caller.Status.IsDone() || !checkedCallers.Add(caller.ID) {
continue
}
freshCaller, err := actions_model.GetRunJobByRunAndID(ctx, runID, caller.ID)
if err != nil {
return false, fmt.Errorf("reloadCallersFinishedByChildren: reload caller %d: %w", caller.ID, err)
}
if !freshCaller.Status.IsDone() {
continue
}
caller.Status, caller.Started, caller.Stopped = freshCaller.Status, freshCaller.Started, freshCaller.Stopped
finishedJobs = append(finishedJobs, caller)
hasFinishedCaller = true
}
return hasFinishedCaller, nil
}
func cancelFailedMatrixSiblings(ctx context.Context, jobs actions_model.ActionJobList) ([]*actions_model.ActionRunJob, error) { func cancelFailedMatrixSiblings(ctx context.Context, jobs actions_model.ActionJobList) ([]*actions_model.ActionRunJob, error) {
var toCancel []*actions_model.ActionRunJob var toCancel []*actions_model.ActionRunJob
for _, failed := range jobs { for _, failed := range jobs {
+60
View File
@@ -5,6 +5,7 @@ package actions
import ( import (
"fmt" "fmt"
"slices"
"testing" "testing"
actions_model "gitea.dev/models/actions" actions_model "gitea.dev/models/actions"
@@ -661,6 +662,65 @@ func Test_checkJobsOfCurrentRunAttempt_SkippedCallerIsUpdated(t *testing.T) {
require.Len(t, result.UpdatedJobs, 1) require.Len(t, result.UpdatedJobs, 1)
assert.Equal(t, caller.ID, result.UpdatedJobs[0].ID) assert.Equal(t, caller.ID, result.UpdatedJobs[0].ID)
assert.Equal(t, actions_model.StatusSkipped, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: caller.ID}).Status) assert.Equal(t, actions_model.StatusSkipped, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: caller.ID}).Status)
// A child skipped in this pass finishes its caller and the outer caller, both are reported Success and the outer one's dependent is resolved by the re-emit.
run2 := &actions_model.ActionRun{
RepoID: 4, OwnerID: 1, TriggerUserID: 1,
WorkflowID: "test.yml", Index: 9915, Ref: "refs/heads/main", Status: actions_model.StatusRunning,
}
assert.NoError(t, db.Insert(ctx, run2))
attempt2 := &actions_model.ActionRunAttempt{RepoID: 4, RunID: run2.ID, Attempt: 1, Status: actions_model.StatusRunning}
assert.NoError(t, db.Insert(ctx, attempt2))
_, err = db.Exec(ctx, "UPDATE `action_run` SET latest_attempt_id = ? WHERE id = ?", attempt2.ID, run2.ID)
assert.NoError(t, err)
run2.LatestAttemptID = attempt2.ID
caller2 := &actions_model.ActionRunJob{
RunID: run2.ID, RunAttemptID: attempt2.ID, RepoID: 4, OwnerID: 1,
JobID: "deploy", Name: "deploy", Status: actions_model.StatusRunning, IsReusableCaller: true, IsExpanded: true,
WorkflowPayload: []byte("jobs: {deploy: {uses: ./.gitea/workflows/mid.yml}}"),
ReusableWorkflowContent: []byte("on: {workflow_call: {}}\njobs: {inner: {uses: ./.gitea/workflows/leaf.yml}}"),
}
assert.NoError(t, db.Insert(ctx, caller2))
inner := &actions_model.ActionRunJob{
RunID: run2.ID, RunAttemptID: attempt2.ID, RepoID: 4, OwnerID: 1, ParentJobID: caller2.ID,
JobID: "inner", Name: "inner", Status: actions_model.StatusRunning, IsReusableCaller: true, IsExpanded: true,
WorkflowPayload: []byte("jobs: {inner: {uses: ./.gitea/workflows/leaf.yml}}"),
}
assert.NoError(t, db.Insert(ctx, inner))
assert.NoError(t, db.Insert(ctx, &actions_model.ActionRunJob{
RunID: run2.ID, RunAttemptID: attempt2.ID, RepoID: 4, OwnerID: 1, ParentJobID: inner.ID,
JobID: "work", Status: actions_model.StatusSuccess, WorkflowPayload: minimalWorkflowPayload("work"),
}))
assert.NoError(t, db.Insert(ctx, &actions_model.ActionRunJob{
RunID: run2.ID, RunAttemptID: attempt2.ID, RepoID: 4, OwnerID: 1, ParentJobID: inner.ID,
JobID: "alert", Status: actions_model.StatusBlocked, Needs: []string{"work"},
WorkflowPayload: []byte("jobs: {alert: {if: false, needs: [work], runs-on: ubuntu-latest, steps: [{run: echo}]}}"),
}))
after := &actions_model.ActionRunJob{
RunID: run2.ID, RunAttemptID: attempt2.ID, RepoID: 4, OwnerID: 1,
JobID: "after", Status: actions_model.StatusPending, Needs: []string{"deploy"},
WorkflowPayload: []byte("jobs: {after: {if: \"github.ref == 'refs/heads/main'\", needs: [deploy], runs-on: ubuntu-latest, steps: [{run: echo}]}}"),
}
assert.NoError(t, db.Insert(ctx, after))
result, err = checkJobsOfCurrentRunAttempt(ctx, run2)
assert.NoError(t, err)
assert.Contains(t, result.RunIDsToReEmit, run2.ID)
assert.Equal(t, actions_model.StatusPending, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: after.ID}).Status)
updatedJobIDs := make([]string, 0, len(result.UpdatedJobs))
for _, job := range result.UpdatedJobs {
updatedJobIDs = append(updatedJobIDs, job.JobID)
}
assert.Equal(t, []string{"alert"}, updatedJobIDs)
for _, callerID := range []int64{inner.ID, caller2.ID} {
idx := slices.IndexFunc(result.Jobs, func(job *actions_model.ActionRunJob) bool { return job.ID == callerID })
require.NotEqual(t, -1, idx)
assert.Equal(t, actions_model.StatusSuccess, result.Jobs[idx].Status)
}
_, err = checkJobsOfCurrentRunAttempt(ctx, run2)
assert.NoError(t, err)
assert.Equal(t, actions_model.StatusWaiting, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: after.ID}).Status)
} }
// Test_checkRunConcurrency_HeldGroupDoesNotWake verifies that only an unoccupied concurrency group can wake up a blocked run/job. // Test_checkRunConcurrency_HeldGroupDoesNotWake verifies that only an unoccupied concurrency group can wake up a blocked run/job.
+1 -1
View File
@@ -224,7 +224,7 @@ func handlePullRequestAutoMerge(ctx context.Context, pr *issues_model.PullReques
// although expectedHeadCommitID is checked before, we should pass it to the Merge function to // although expectedHeadCommitID is checked before, we should pass it to the Merge function to
// make it be checked again in case the head commit id changed after the previous check. // make it be checked again in case the head commit id changed after the previous check.
if err := pull_service.Merge(pr.ID, doer, scheduledPRM.MergeStyle, expectedHeadCommitID, scheduledPRM.Message, true); err != nil { if err := pull_service.Merge(ctx, pr.ID, doer, scheduledPRM.MergeStyle, expectedHeadCommitID, scheduledPRM.Message, true); err != nil {
if pull_service.IsErrSHADoesNotMatch(err) { if pull_service.IsErrSHADoesNotMatch(err) {
return errors.Join(errSkipAutoMerge, err) return errors.Join(errSkipAutoMerge, err)
} }
-5
View File
@@ -211,11 +211,6 @@ func (ctx *Context) DoerNeedTwoFactorAuth() bool {
return ctx.Session.Get(session.KeyUserHasTwoFactorAuth) == false return ctx.Session.Get(session.KeyUserHasTwoFactorAuth) == false
} }
// DoerIsImpersonated returns true if the current session is an admin impersonating the doer
func (ctx *Context) DoerIsImpersonated() bool {
return ctx.Session.Get(session.KeyImpersonatorData) != nil
}
// HasError returns true if error occurs in form validation. // HasError returns true if error occurs in form validation.
// Attention: this function changes ctx.Data and ctx.Flash // Attention: this function changes ctx.Data and ctx.Flash
// If HasError is called, then before Redirect, the error message should be stored by ctx.Flash.Error(ctx.GetErrMsg()) again. // If HasError is called, then before Redirect, the error message should be stored by ctx.Flash.Error(ctx.GetErrMsg()) again.
+1 -1
View File
@@ -69,7 +69,7 @@ func (c TemplateContext) CurrentWebTheme() *webtheme.ThemeMetaInfo {
func (c TemplateContext) ImpersonatedUser() *user_model.User { func (c TemplateContext) ImpersonatedUser() *user_model.User {
webCtx := GetWebContext(c) webCtx := GetWebContext(c)
if webCtx == nil || webCtx.Doer == nil || !webCtx.DoerIsImpersonated() { if webCtx == nil || webCtx.Doer == nil || !IsDoerSessionImpersonated(webCtx.Session) {
return nil return nil
} }
return webCtx.Doer return webCtx.Doer
+1 -1
View File
@@ -190,7 +190,7 @@ func PrepareCommitFormOptions(ctx *Context, doer *user_model.User, targetRepo *r
protectionRequireSigned := false protectionRequireSigned := false
if protectedBranch != nil { if protectedBranch != nil {
protectedBranch.Repo = targetRepo protectedBranch.Repo = targetRepo
canPushWithProtection = protectedBranch.CanUserPush(ctx, doer) canPushWithProtection = protectedBranch.CanUserPush(ctx, doer, doerRepoPerm)
protectionRequireSigned = protectedBranch.RequireSignedCommits protectionRequireSigned = protectedBranch.RequireSignedCommits
// If branch-wide push is restricted, allow direct commit when the // If branch-wide push is restricted, allow direct commit when the
// URL-derived tree path matches an unprotected file pattern. The // URL-derived tree path matches an unprotected file pattern. The
+5
View File
@@ -9,6 +9,7 @@ import (
"strings" "strings"
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
"gitea.dev/modules/session"
) )
// UserAssignmentWeb returns a middleware to handle context-user assignment for web routes // UserAssignmentWeb returns a middleware to handle context-user assignment for web routes
@@ -58,3 +59,7 @@ func userAssignment(ctx *Base, doer *user_model.User, errCb func(int, string)) (
} }
return contextUser return contextUser
} }
func IsDoerSessionImpersonated(sess session.Store) bool {
return sess.Get(session.KeyImpersonatorData) != nil
}
+1 -1
View File
@@ -113,7 +113,7 @@ func ToBranch(ctx context.Context, repo *repo_model.Repository, branchName strin
return nil, err return nil, err
} }
bp.Repo = repo bp.Repo = repo
branch.UserCanPush = bp.CanUserPush(ctx, user) branch.UserCanPush = bp.CanUserPush(ctx, user, permission)
branch.UserCanMerge = git_model.IsUserMergeWhitelisted(ctx, bp, user.ID, permission) branch.UserCanMerge = git_model.IsUserMergeWhitelisted(ctx, bp, user.ID, permission)
} }
+16 -1
View File
@@ -14,6 +14,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"unicode" "unicode"
"uuid"
"gitea.dev/models/db" "gitea.dev/models/db"
git_model "gitea.dev/models/git" git_model "gitea.dev/models/git"
@@ -27,6 +28,7 @@ import (
"gitea.dev/modules/git/gitcmd" "gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/globallock" "gitea.dev/modules/globallock"
"gitea.dev/modules/graceful" "gitea.dev/modules/graceful"
"gitea.dev/modules/gtprof"
"gitea.dev/modules/httplib" "gitea.dev/modules/httplib"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/references" "gitea.dev/modules/references"
@@ -289,9 +291,22 @@ func hasPullRequestCommitBeenMerged(ctx context.Context, pr *issues_model.PullRe
// Merge merges pull request to base repository. // Merge merges pull request to base repository.
// Caller should check PR is ready to be merged (review and status checks) // Caller should check PR is ready to be merged (review and status checks)
func Merge(prID int64, doer *user_model.User, mergeStyle repo_model.MergeStyle, expectedHeadCommitID, message string, wasAutoMerged bool) error { func Merge(outerCtx context.Context, prID int64, doer *user_model.User, mergeStyle repo_model.MergeStyle, expectedHeadCommitID, message string, wasAutoMerged bool) error {
outerCtxId := uuid.NewV4().String()
_, outerSpan := gtprof.GetTracer().Start(outerCtx, gtprof.TraceSpanContext)
outerSpan.SetAttributeString("context.trace-id", outerCtxId) // this attribute is only used internally for debugging purpose
defer outerSpan.End()
// TODO: in the future, the contexts from graceful.GetManager() should be wrapped with gtprof tracing, refactor the code to framework-level support
ctx := graceful.GetManager().HammerContext() // don't abort the git operation even if the user's request is canceled ctx := graceful.GetManager().HammerContext() // don't abort the git operation even if the user's request is canceled
ctx, span := gtprof.GetTracer().Start(ctx, gtprof.TraceSpanContext)
span.SetAttributeString(gtprof.TraceAttrGeneralName, "merge-pull-request")
span.SetAttributeString(gtprof.TraceAttrGeneralDesc, fmt.Sprintf("merge pull request %d with merge style %s", prID, mergeStyle))
span.SetAttributeString("context.trace-id-outer", outerCtxId) // this attribute is only used internally for debugging purpose
defer span.End()
err := globallock.LockAndDo(ctx, getPullWorkingLockKey(prID), func(ctx context.Context) error { err := globallock.LockAndDo(ctx, getPullWorkingLockKey(prID), func(ctx context.Context) error {
pr, err := issues_model.GetPullRequestByID(ctx, prID) pr, err := issues_model.GetPullRequestByID(ctx, prID)
if err != nil { if err != nil {
+11 -18
View File
@@ -556,11 +556,10 @@ func checkIfPRContentChanged(ctx context.Context, pr *issues_model.PullRequest,
return false, mergeBase, nil return false, mergeBase, nil
} }
// PushToBaseRepo pushes commits from branches of head repository to // PushToBaseRepo fetches the head branch commit into the base repository and points the PR head ref at it.
// corresponding branches of base repository. // FIXME: Only update refs that actually changed?
// FIXME: Only push branches that are actually updates?
func PushToBaseRepo(ctx context.Context, pr *issues_model.PullRequest) error { func PushToBaseRepo(ctx context.Context, pr *issues_model.PullRequest) error {
log.Trace("PushToBaseRepo[%d]: pushing commits to base repo '%s'", pr.BaseRepoID, pr.GetGitHeadRefName()) log.Trace("PushToBaseRepo[%d]: updating base repo ref '%s'", pr.BaseRepoID, pr.GetGitHeadRefName())
if err := pr.LoadHeadRepo(ctx); err != nil { if err := pr.LoadHeadRepo(ctx); err != nil {
return err return err
@@ -571,23 +570,17 @@ func PushToBaseRepo(ctx context.Context, pr *issues_model.PullRequest) error {
if err := pr.LoadIssue(ctx); err != nil { if err := pr.LoadIssue(ctx); err != nil {
return err return err
} }
if err := pr.Issue.LoadPoster(ctx); err != nil {
headCommitID, err := git.GetFullCommitID(ctx, pr.HeadRepo, git.BranchPrefix+pr.HeadBranch)
if err != nil {
return err return err
} }
// fetch, not push: pushing objects FetchRemoteCommit already fetched races background repacks
baseRepoHeadRefName := pr.GetGitHeadRefName() if err := git.FetchRemoteCommit(ctx, pr.BaseRepo, pr.HeadRepo, headCommitID); err != nil {
if err := git.PushManaged(ctx, pr.HeadRepo, pr.BaseRepo, git.PushOptions{ return fmt.Errorf("unable to fetch head branch %s:%s into base repo %s, err: %w",
Branch: git.BranchPrefix + pr.HeadBranch + ":" + baseRepoHeadRefName, pr.HeadRepo.FullName(), pr.HeadBranch, pr.BaseRepo.FullName(), err)
Force: true,
// Use InternalPushingEnvironment here because we know that pre-receive and post-receive do not run on a refs/pulls/...
Env: repo_module.InternalPushingEnvironment(pr.Issue.Poster, pr.BaseRepo),
}); err != nil {
// Since we use internal force-push, there should be no git error.
// If any error happens, it must be an internal error (e.g.: broken git hooks) but not user error.
return fmt.Errorf("unable to push from head branch %s:%s to base repo %s:%s, err: %w",
pr.HeadRepo.FullName(), pr.HeadBranch, pr.BaseRepo.FullName(), baseRepoHeadRefName, err)
} }
return nil return git.UpdateRef(ctx, pr.BaseRepo, pr.GetGitHeadRefName(), headCommitID)
} }
// UpdatePullsRefs update all the PRs head file pointers like /refs/pull/1/head so that it will be dependent by other operations // UpdatePullsRefs update all the PRs head file pointers like /refs/pull/1/head so that it will be dependent by other operations

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