Compare commits

..

22 Commits

Author SHA1 Message Date
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
98 changed files with 1102 additions and 559 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: go-gitea/giteabot@4c9d4d3fd913b5c35f59dcc0b004a3d11d5b22bf # v1.0.7
- uses: go-gitea/giteabot@f48c6a15e0d384f037aea19cc05ff5e9551096b9 # v1.1.0
with:
github_token: ${{ secrets.GITEABOT_TOKEN }}
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
- review_requested
- review_request_removed
# Review events keep review-derived state such as lgtm labels and status checks
# in sync after approvals, edits, or dismissals.
pull_request_review:
# Reviews arrive through giteabot-review because fork PR review runs get no secrets
workflow_run:
workflows:
- giteabot-review
types:
- submitted
- edited
- dismissed
- requested
# Periodic maintenance is still useful as a backstop for queue cleanup and
# other housekeeping, even though main pushes now trigger it promptly.
schedule:
@@ -43,12 +42,12 @@ on:
permissions: {}
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
jobs:
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
timeout-minutes: 30
permissions:
@@ -57,9 +56,7 @@ jobs:
pull-requests: write
statuses: write
steps:
# pull_request_review runs without repository secrets on fork PRs, so fall
# back to the workflow token for the non-backport checks handled here.
- uses: go-gitea/giteabot@4c9d4d3fd913b5c35f59dcc0b004a3d11d5b22bf # v1.0.7
- uses: go-gitea/giteabot@f48c6a15e0d384f037aea19cc05ff5e9551096b9 # v1.1.0
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' }}
+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
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
- Never call `x.Update(exemplar)` without an explicit `WHERE` clause — it updates
+1 -1
View File
@@ -109,7 +109,7 @@ require (
modernc.org/sqlite v1.59.0
mvdan.cc/xurls/v2 v2.6.0
xorm.io/builder v0.3.13
xorm.io/xorm v1.4.1
xorm.io/xorm v1.4.3
)
require (
+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=
xorm.io/builder v0.3.13 h1:a3jmiVVL19psGeXx8GIurTp7p0IIgqeDmwhcR6BAOAo=
xorm.io/builder v0.3.13/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=
xorm.io/xorm v1.4.1 h1:m7QlNd0eBGb31IV4Q/ow0Du83rtdC1CiwlvJZGvYde8=
xorm.io/xorm v1.4.1/go.mod h1:cs0ePc8O4a0jD78cNvD+0VFwhqotTvLQZv372QsDw7Q=
xorm.io/xorm v1.4.3 h1:MwWFWzVr+/6D07qGCDhBAfABcuT0gvqY3XmTy1215BM=
xorm.io/xorm v1.4.3/go.mod h1:cs0ePc8O4a0jD78cNvD+0VFwhqotTvLQZv372QsDw7Q=
+2 -3
View File
@@ -295,9 +295,8 @@ func GetRunByRepoAndID(ctx context.Context, repoID, runID int64) (*ActionRun, er
return &run, nil
}
func GetRunsByRepoAndID(ctx context.Context, repoID int64, runIDs []int64) ([]*ActionRun, error) {
var runs []*ActionRun
err := db.GetEngine(ctx).In("id", runIDs).Where("repo_id=?", repoID).Find(&runs)
func GetRunsByRepoAndID(ctx context.Context, repoID int64, runIDs []int64) (runs []*ActionRun, err error) {
err = db.GetEngine(ctx).In("id", runIDs).Where("repo_id=?", repoID).OrderBy("id").Find(&runs)
return runs, err
}
+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 {
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 {
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.
}
// A short page means no waiting jobs remain beyond it.
if len(jobs) < pickTaskBatchSize {
if isLastPage {
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
}
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
func GetOAuth2GrantByID(ctx context.Context, id int64) (grant *OAuth2Grant, err error) {
grant = new(OAuth2Grant)
+20
View File
@@ -5,7 +5,9 @@ package db
import (
"context"
"database/sql"
"fmt"
"time"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
@@ -59,6 +61,11 @@ func InitEngine(ctx context.Context) error {
xe.SetMaxIdleConns(setting.Database.MaxIdleConns)
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 {
xe.AddHook(&EngineHook{
Threshold: setting.Database.SlowQueryThreshold,
@@ -103,6 +110,10 @@ func InitEngineWithMigration(ctx context.Context, migrateFunc func(context.Conte
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.
// 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
}
// 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 {
switch opts.SortType {
case "oldest":
return "created_unix ASC"
return "created_unix ASC, `index` ASC"
case "recentupdate":
return "updated_unix DESC"
return "updated_unix DESC, `index` DESC"
case "leastupdate":
return "updated_unix ASC"
return "updated_unix ASC, `index` ASC"
case "leastindex":
return "`index` DESC"
case "highestindex":
return "`index` ASC"
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.Equal(t, 5, int(maxResults))
assert.Len(t, statuses, 5)
assert.Equal(t, "ci/awesomeness", statuses[0].Context)
assert.Equal(t, commitstatus.CommitStatusPending, statuses[0].State)
var indexes []int64
for _, status := range statuses {
indexes = append(indexes, status.Index)
}
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, "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{
ListOptions: db.ListOptions{Page: 2, PageSize: 50},
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
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 {
return false
}
if !protectBranch.EnableWhitelist {
if err := protectBranch.LoadRepo(ctx); err != nil {
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
return permissionInRepo.CanWrite(unit.TypeCode)
}
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
// 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 {
return false
}
if !protectBranch.EnableForcePushAllowlist {
return protectBranch.CanUserPush(ctx, user)
return protectBranch.CanUserPush(ctx, user, permissionInRepo)
}
if slices.Contains(protectBranch.ForcePushAllowlistUserIDs, user.ID) {
return protectBranch.CanUserPush(ctx, user)
return protectBranch.CanUserPush(ctx, user, permissionInRepo)
}
if len(protectBranch.ForcePushAllowlistTeamIDs) == 0 {
@@ -182,7 +172,7 @@ func (protectBranch *ProtectedBranch) CanUserForcePush(ctx context.Context, user
log.Error("IsUserInTeams: %v", err)
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
+15
View File
@@ -407,6 +407,21 @@ func (pr *PullRequest) GetGitHeadRefName() string { // TODO: make it return RefN
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)
func (pr *PullRequest) GetReviewCommentsCount(ctx context.Context) int {
opts := FindCommentsOptions{
+40 -1
View File
@@ -8,11 +8,13 @@ import (
"fmt"
"html"
"io"
"strings"
"unicode"
"unicode/utf8"
"gitea.dev/modules/setting"
"gitea.dev/modules/translation"
"gitea.dev/modules/util"
)
type htmlChunkReader struct {
@@ -30,6 +32,10 @@ type escapeStreamer struct {
ambiguousTables []*AmbiguousTable
allowed map[rune]bool
tagPartial []byte // partial tag content, used to detect if we are in some tags
inTagMath bool // MathML operators like U+2212 are intended and wrapping them breaks the math layout
out io.Writer
}
@@ -62,6 +68,7 @@ func escapeStream(locale translation.Locale, in io.Reader, out io.Writer, opts .
for i, part := range parts {
if partInTag[i] {
lastIsTag = true
es.trackHtmlTag(part)
if _, err := out.Write(part); err != nil {
return nil, err
}
@@ -75,7 +82,11 @@ func escapeStream(locale translation.Locale, in io.Reader, out io.Writer, opts .
return nil, err
}
}
if err = es.detectAndWriteRunes(part); err != nil {
if es.inTagMath {
if _, err := out.Write(part); err != nil {
return nil, err
}
} else if err = es.detectAndWriteRunes(part); err != nil {
return nil, err
}
}
@@ -83,6 +94,34 @@ func escapeStream(locale translation.Locale, in io.Reader, out io.Writer, opts .
}
}
// trackHtmlTag receives tag parts, a tag might be split into multiple parts
func (e *escapeStreamer) trackHtmlTag(part []byte) {
const maxHeadLen = 100 // only read the first N bytes of the tag for detection purpose
if part[0] == '<' {
// start a new tag
e.tagPartial = e.tagPartial[:0]
}
if len(e.tagPartial) >= maxHeadLen {
return
}
e.tagPartial = append(e.tagPartial, part[:min(len(part), maxHeadLen-len(e.tagPartial))]...)
isTag := func(prefix string) bool {
if len(e.tagPartial) < len(prefix)+1 {
return false
}
if !util.AsciiEqualFold(e.tagPartial[:len(prefix)], []byte(prefix)) {
return false
}
return strings.IndexByte(" \t\n\r\f>", e.tagPartial[len(prefix)]) != -1
}
if isTag("<math") {
e.inTagMath = true
} else if isTag("</math") {
e.inTagMath = false
}
}
func (e *escapeStreamer) trimAndWriteBom(part []byte) ([]byte, error) {
remaining, ok := bytes.CutPrefix(part, globalVars().utf8Bom)
if ok {
+24
View File
@@ -141,6 +141,12 @@ then resh (ר), and finally heh (ה) (which should appear leftmost).`,
result: `O<span class="ambiguous-code-point" data-tooltip-content="repo.ambiguous_character:𝐾 [U+1D43E],K [U+004B]"><span class="char">𝐾</span></span>`,
status: EscapeStatus{Escaped: true, HasAmbiguous: true},
},
{
name: "ambiguous in math",
text: "<math><mo>−</mo><mi>b</mi></math> −",
result: `<math><mo>−</mo><mi>b</mi></math> <span class="ambiguous-code-point" data-tooltip-content="repo.ambiguous_character:− [U+2212],- [U+002D]"><span class="char">−</span></span>`,
status: EscapeStatus{Escaped: true, HasAmbiguous: true},
},
}
func TestEscapeControlReader(t *testing.T) {
@@ -156,6 +162,24 @@ func TestEscapeControlReader(t *testing.T) {
}
}
func TestTrackHtmlTag(t *testing.T) {
e := &escapeStreamer{}
for _, tt := range []struct {
parts []string
inMath bool
}{
{[]string{"<ma", `TH display="block">`}, true},
{[]string{"<mo>"}, true},
{[]string{"</MA", "th>"}, false},
{[]string{"<mathx>"}, false},
} {
for _, part := range tt.parts {
e.trackHtmlTag([]byte(part))
}
assert.Equal(t, tt.inMath, e.inTagMath, "%v", tt.parts)
}
}
func TestSettingAmbiguousUnicodeDetection(t *testing.T) {
defer test.MockVariableValue(&setting.UI.AmbiguousUnicodeDetection, true)()
_, out := EscapeControlHTML("a test", &translation.MockLocale{})
+8 -2
View File
@@ -13,6 +13,7 @@ import (
"gitea.dev/modules/git"
"gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
)
// 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)
}
timeout := time.NewTimer(5 * time.Second)
defer timeout.Stop()
rs = NewAttributes()
for i := 0; i < c.attributesNum; i++ {
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.
return nil, reportTimeout()
err = reportTimeout()
setting.PanicInDevOrTesting("Unexpected timeout, need to investigate: %v", err)
return nil, err
case attr, ok := <-c.stdOut.ReadAttribute():
if !ok {
return nil, c.ctx.Err()
+10 -5
View File
@@ -49,8 +49,8 @@ type Command struct {
cmd *process.Cmd
cmdCtx context.Context
cmdCancel process.CancelCauseFunc
cmdFinished process.FinishedFunc
cmdCtxCancel process.CancelCauseFunc
cmdFinished func()
cmdStartTime time.Time
pipelineFunc func(Context) error
@@ -428,19 +428,24 @@ func (c *Command) Start(ctx context.Context) (retErr error) {
if c.callerInfo == "" {
c.WithParentCallerInfo()
}
// 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)
log.Debug("git.Command: %s", desc)
_, span := gtprof.GetTracer().Start(ctx, gtprof.TraceSpanGitRun)
defer span.End()
span.SetAttributeString(gtprof.TraceAttrFuncCaller, c.callerInfo)
span.SetAttributeString(gtprof.TraceAttrGitCommand, cmdLogString)
var cmdCtxFinished func()
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 {
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()
+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 parent context (still context.Canceled error)
// * other causes
c.cmd.cmdCancel(pipelineError{err})
c.cmd.cmdCtxCancel(pipelineError{err})
return err
}
+91 -13
View File
@@ -8,9 +8,13 @@ package git
import (
"errors"
"io"
"os"
"path/filepath"
"slices"
"strings"
"gitea.dev/modules/container"
"gitea.dev/modules/git/gitrepo"
"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/cache"
"github.com/go-git/go-git/v5/storage/filesystem"
"github.com/go-git/go-git/v5/storage/filesystem/dotgit"
)
const isGogit = true
@@ -31,25 +36,98 @@ type Repository struct {
gogitStorage *reindexingStorage
}
// reindexingStorage picks up packs that git wrote after go-git loaded its index
// https://github.com/go-git/go-git/issues/2439
// 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/1623
// FIXME: gogit workaround, remove with the gogit build
type reindexingStorage struct {
*filesystem.Storage
packs []plumbing.Hash
}
func (s *reindexingStorage) EncodedObject(t plumbing.ObjectType, h plumbing.Hash) (plumbing.EncodedObject, error) {
obj, err := s.Storage.EncodedObject(t, h)
if !errors.Is(err, plumbing.ErrObjectNotFound) {
return obj, err
func isRepackError(err error) bool {
return errors.Is(err, plumbing.ErrObjectNotFound) || errors.Is(err, dotgit.ErrPackfileNotFound) || errors.Is(err, os.ErrNotExist)
}
// 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
s.Reindex()
return s.Storage.EncodedObject(t, h)
if _, ok := obj.(*plumbing.MemoryObject); ok {
return obj, nil
}
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 {
@@ -71,7 +149,7 @@ func openRepositoryInternal(gitRepo *Repository) error {
altFs = osfs.New("/")
}
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()
gitRepo.gogitStorage = &reindexingStorage{Storage: storage, packs: packs}
gitRepo.gogitRepo, err = gogit.Open(gitRepo.gogitStorage, fs)
+41
View File
@@ -4,9 +4,15 @@
package git
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/setting"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -39,6 +45,41 @@ func TestRepository_GetBranches(t *testing.T) {
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) {
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
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
}
// 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
for internalSpanIdx, tsp := range starters {
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
const (
TraceSpanContext = "context"
TraceSpanHTTP = "http"
TraceSpanGitRun = "git-run"
TraceSpanDatabase = "database"
)
const (
TraceAttrFuncCaller = "func.caller"
TraceAttrDbSQL = "db.sql"
TraceAttrGitCommand = "git.command"
TraceAttrHTTPRoute = "http.route"
TraceAttrGeneralName = "general.name"
TraceAttrGeneralDesc = "general.desc"
TraceAttrFuncCaller = "func.caller"
TraceAttrDbSQL = "db.sql"
TraceAttrGitCommand = "git.command"
TraceAttrHTTPRoute = "http.route"
)
+4
View File
@@ -45,6 +45,10 @@ func MarshalKeepOptionalEmpty(v any) ([]byte, error) {
return jsonv2.Marshal(v, jsonV2.marshalKeepOptionalEmptyOptions)
}
func MarshalDeterministic(v any) ([]byte, error) {
return jsonv2.Marshal(v, jsonV2.marshalOptions, jsonv2.Deterministic(true))
}
func (j *JSONv2) Marshal(v any) ([]byte, error) {
return jsonv2.Marshal(v, j.marshalOptions)
}
+2 -2
View File
@@ -349,8 +349,8 @@ func visitNode(ctx *RenderContext, procs []processor, node *html.Node) *html.Nod
// TextNode emoji will be converted to `<span class="emoji">`, then the next iteration will visit the "span"
// if we don't stop it, it will go into the TextNode again and create an infinite recursion
return node.NextSibling
} else if node.Data == "code" || node.Data == "pre" {
return node.NextSibling // ignore code and pre nodes
} else if node.Data == "code" || node.Data == "pre" || node.Data == "math" {
return node.NextSibling // ignore code, pre and math nodes
} else if node.Data == "img" {
return visitNodeImg(ctx, node)
} else if node.Data == "video" {
+3
View File
@@ -543,6 +543,9 @@ func TestPostProcess(t *testing.T) {
`Some text with <span class="emoji" data-alias="smile">😄</span> in the middle`)
test("http://localhost:3000/person/repo/issues/4#issuecomment-1234",
`<a href="http://localhost:3000/person/repo/issues/4#issuecomment-1234" class="ref-issue">person/repo#4 (comment)</a>`)
test(
"<math><mtext>:gitea: go-gitea/gitea#12345</mtext></math>",
"<math><mtext>:gitea: go-gitea/gitea#12345</mtext></math>")
// special tags, GitHub's behavior, and for unclosed tags, output as text content as much as possible
test("<script>a", `&lt;script&gt;a`)
+23 -51
View File
@@ -121,6 +121,7 @@ type PackageMetadataVersion struct {
Engines map[string]string `json:"engines,omitempty"`
CPU []string `json:"cpu,omitempty"`
OS []string `json:"os,omitempty"`
Libc []string `json:"libc,omitempty"`
Directories map[string]string `json:"directories,omitempty"`
Funding any `json:"funding,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
type PackageDistribution struct {
Integrity string `json:"integrity"`
Shasum string `json:"shasum"`
Tarball string `json:"tarball"`
FileCount int `json:"fileCount,omitempty"`
UnpackedSize int `json:"unpackedSize,omitempty"`
NpmSignature string `json:"npm-signature,omitempty"`
Integrity string `json:"integrity"`
Shasum string `json:"shasum"`
Tarball string `json:"tarball"`
}
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,
// 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
// 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,
// otherwise it is a "publish".
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 {
return nil, nil, err
}
@@ -280,16 +278,6 @@ func ParseUpload(r io.Reader) (*Package, *PackageDeprecation, error) {
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.
func parseUploadPackage(upload *packageUpload) (*Package, error) {
for _, meta := range upload.Versions {
@@ -343,6 +331,7 @@ func parseUploadPackage(upload *packageUpload) (*Package, error) {
Engines: meta.Engines,
CPU: meta.CPU,
OS: meta.OS,
Libc: meta.Libc,
Directories: meta.Directories,
Funding: meta.Funding,
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))
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 {
return a
attachment = a
}
return nil
}()
}
if attachment == nil || len(attachment.Data) == 0 {
return nil, ErrInvalidAttachment
}
@@ -393,8 +382,6 @@ func parseUploadPackage(upload *packageUpload) (*Package, error) {
return nil, ErrInvalidIntegrity
}
// Derive _hasShrinkwrap and hasInstallScript from the tarball; the
// packument can lie about either.
p.Metadata.HasShrinkwrap, p.Metadata.HasInstallScript = inspectTarball(data)
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.
const maxNpmPackageJSONBytes = int64(1 * 1024 * 1024) // 1 MiB
// inspectTarball reports hasShrinkwrap (presence of package/npm-shrinkwrap.json)
// 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.
// inspectTarball trusts the tarball over the client's packument, read errors yield zero values to not block publishing
func inspectTarball(data []byte) (hasShrinkwrap, hasInstallScript bool) {
gr, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
@@ -422,11 +405,16 @@ func inspectTarball(data []byte) (hasShrinkwrap, hasInstallScript bool) {
}
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))
for {
hdr, err := tr.Next()
if err != nil {
return hasShrinkwrap, hasInstallScript
break
}
// npm pack puts files under a single root directory (usually "package/").
name := strings.TrimPrefix(hdr.Name, "./")
@@ -436,30 +424,14 @@ func inspectTarball(data []byte) (hasShrinkwrap, hasInstallScript bool) {
switch {
case strings.HasSuffix(name, "/npm-shrinkwrap.json"):
hasShrinkwrap = true
case strings.HasSuffix(name, ".gyp"):
hasGypFile = true
case strings.HasSuffix(name, "/package.json"):
hasInstallScript = tarballDeclaresInstallScript(tr)
}
if hasShrinkwrap && hasInstallScript {
return hasShrinkwrap, hasInstallScript
_ = json.NewDecoder(io.LimitReader(tr, maxNpmPackageJSONBytes)).Decode(&pkg)
}
}
}
// 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
// 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
}
func validateName(name string) bool {
+24 -54
View File
@@ -41,21 +41,20 @@ func TestParsePackage(t *testing.T) {
integrity := "sha512-" + base64Sha512(dataBytes)
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.Error(t, err)
})
t.Run("InvalidUploadNoData", func(t *testing.T) {
b, _ := json.Marshal(packageUpload{})
p, err := ParsePackage(bytes.NewReader(b))
p, err := parseUploadPackage(&packageUpload{})
assert.Nil(t, p)
assert.ErrorIs(t, err, ErrInvalidPackage)
})
t.Run("InvalidPackageName", func(t *testing.T) {
test := func(t *testing.T, name string) {
b, _ := json.Marshal(packageUpload{
p, err := parseUploadPackage(&packageUpload{
PackageMetadata: PackageMetadata{
ID: name,
Name: name,
@@ -66,8 +65,6 @@ func TestParsePackage(t *testing.T) {
},
},
})
p, err := ParsePackage(bytes.NewReader(b))
assert.Nil(t, p)
assert.ErrorIs(t, err, ErrInvalidPackageName)
}
@@ -94,7 +91,7 @@ func TestParsePackage(t *testing.T) {
t.Run("ValidPackageName", func(t *testing.T) {
test := func(t *testing.T, name string) {
b, _ := json.Marshal(packageUpload{
p, err := parseUploadPackage(&packageUpload{
PackageMetadata: PackageMetadata{
ID: name,
Name: name,
@@ -105,8 +102,6 @@ func TestParsePackage(t *testing.T) {
},
},
})
p, err := ParsePackage(bytes.NewReader(b))
assert.Nil(t, p)
assert.ErrorIs(t, err, ErrInvalidPackageVersion)
}
@@ -125,7 +120,7 @@ func TestParsePackage(t *testing.T) {
t.Run("InvalidPackageVersion", func(t *testing.T) {
version := "first-version"
b, _ := json.Marshal(packageUpload{
p, err := parseUploadPackage(&packageUpload{
PackageMetadata: PackageMetadata{
ID: packageFullName,
Name: packageFullName,
@@ -137,8 +132,6 @@ func TestParsePackage(t *testing.T) {
},
},
})
p, err := ParsePackage(bytes.NewReader(b))
assert.Nil(t, p)
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.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.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.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.ErrorIs(t, err, ErrInvalidIntegrity)
})
@@ -281,10 +274,13 @@ func TestParsePackage(t *testing.T) {
filename: {
Data: data,
},
packageFullName + "-" + packageVersion + ".sigstore": {
Data: "{}",
},
},
})
p, err := ParsePackage(bytes.NewReader(b))
p, _, err := ParseUpload(bytes.NewReader(b))
assert.NotNil(t, p)
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.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.Equal(t, "https://gitea.io/gitea/test.git", p.Metadata.Repository.URL)
// a string bin is named after the package
@@ -426,6 +422,15 @@ func TestInspectTarball(t *testing.T) {
// npm pack sometimes emits "./package/..." entries.
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 {
t.Run(c.name, func(t *testing.T) {
@@ -460,41 +465,6 @@ func TestParseUpload(t *testing.T) {
require.NotNil(t, dep)
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 {
+1
View File
@@ -29,6 +29,7 @@ type Metadata struct {
Engines map[string]string `json:"engines,omitempty"`
CPU []string `json:"cpu,omitempty"`
OS []string `json:"os,omitempty"`
Libc []string `json:"libc,omitempty"`
Directories map[string]string `json:"directories,omitempty"`
Funding any `json:"funding,omitempty"`
AcceptDependencies map[string]string `json:"accept_dependencies,omitempty"`
+2 -1
View File
@@ -36,6 +36,7 @@ import "strings"
const (
tildePrefix = '~'
commentPrefix = '#'
needsEscape = " \t\n|&;()<>${}[]*?!\"'`\\"
needsSingleQuote = "!\n"
)
@@ -74,7 +75,7 @@ func ShellEscape(toEscape string) 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
}
+4
View File
@@ -75,6 +75,10 @@ func TestShellEscape(t *testing.T) {
"Double quote and escape `...",
"~/gitea`",
"~/\"gitea\\`\"",
}, {
"Double quote leading #",
"#123",
`"#123"`,
}, {
"Double quotes can handle a number of things without having to escape them but not everything ...",
"~/<gitea> ${gitea} `gitea` [gitea] (gitea) \"gitea\" \\gitea\\ 'gitea'",
+1 -1
View File
@@ -121,7 +121,7 @@ func asciiLower(b byte) byte {
// AsciiEqualFold is from Golang https://cs.opensource.google/go/go/+/refs/tags/go1.24.4:src/net/http/internal/ascii/print.go
// ASCII only. In most cases for protocols, we should only use this but not [strings.EqualFold]
func AsciiEqualFold(s, t string) bool {
func AsciiEqualFold[T string | []byte](s, t T) bool {
if len(s) != len(t) {
return false
}
+14 -2
View File
@@ -5,6 +5,7 @@ package web
import (
"net/http"
"net/url"
"regexp"
"slices"
"strings"
@@ -19,13 +20,17 @@ type RouterPathGroup struct {
r *Router
pathParam string
matchers []*routerPathMatcher
unescape bool
}
func (g *RouterPathGroup) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
chiCtx := chi.RouteContext(req.Context())
path := chiCtx.URLParam(g.pathParam)
if g.unescape {
path, _ = url.PathUnescape(path)
}
for _, m := range g.matchers {
if m.matchPath(chiCtx, path) {
if m.matchPath(chiCtx, path, g.unescape) {
chiCtx.RoutePatterns = append(chiCtx.RoutePatterns, m.pattern)
executeMiddlewaresHandler(resp, req, m.middlewares, m.handlerFunc)
return
@@ -53,6 +58,10 @@ func (g *RouterPathGroup) MatchPattern(methods string, pattern *RouterPathGroupP
g.matchers = append(g.matchers, newRouterPathMatcher(methods, pattern, h...))
}
func (g *RouterPathGroup) UseUnescapedPath() {
g.unescape = true
}
type routerPathParam struct {
name string
pathSepEnd bool
@@ -68,7 +77,7 @@ type routerPathMatcher struct {
handlerFunc http.HandlerFunc
}
func (p *routerPathMatcher) matchPath(chiCtx *chi.Context, path string) bool {
func (p *routerPathMatcher) matchPath(chiCtx *chi.Context, path string, unescaped bool) bool {
if !p.methods.Contains(chiCtx.RouteMethod) {
return false
}
@@ -102,6 +111,9 @@ func (p *routerPathMatcher) matchPath(chiCtx *chi.Context, path string) bool {
if p.params[i].pathSepEnd {
val = strings.TrimSuffix(val, "/")
}
if unescaped {
val = url.PathEscape(val)
}
chiCtx.URLParams.Add(p.params[i].name, val)
}
return true
+9 -1
View File
@@ -7,6 +7,7 @@ import (
"bytes"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
@@ -97,12 +98,16 @@ func (r *testRecorder) test(t *testing.T, rt *Router, methodPath string, expecte
}
func TestPathProcessor(t *testing.T) {
unescape := false
testProcess := func(pattern, uri string, expectedPathParams map[string]string) {
chiCtx := chi.NewRouteContext()
chiCtx.RouteMethod = "GET"
p := newRouterPathMatcher("GET", patternRegexp(pattern), http.NotFound)
shouldProcess := expectedPathParams != nil
assert.Equal(t, shouldProcess, p.matchPath(chiCtx, uri), "use pattern %s to process uri %s", pattern, uri)
if unescape {
uri, _ = url.PathUnescape(uri)
}
assert.Equal(t, shouldProcess, p.matchPath(chiCtx, uri, unescape), "use pattern %s to process uri %s", pattern, uri)
assert.Equal(t, expectedPathParams, chiURLParamsToMap(chiCtx), "use pattern %s to process uri %s", pattern, uri)
}
@@ -119,6 +124,9 @@ func TestPathProcessor(t *testing.T) {
testProcess("/<p1:*>/part/<p2>", "/part/c", map[string]string{"p1": "", "p2": "c"})
testProcess("/<p1:*>/part/<p2>", "/a/other-part/c", nil)
testProcess("/<p1:*>-part/<p2>", "/a-other-part/c", map[string]string{"p1": "a-other", "p2": "c"})
unescape = true
testProcess("/<p1:@/x>", "/%40%2fx", map[string]string{"p1": "@%2Fx"})
}
func TestRouter(t *testing.T) {
+1
View File
@@ -433,6 +433,7 @@
"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_with_scopes": "With scopes: %s",
"auth.authorize_application_new_scopes": "New scopes: %s",
"auth.authorize_title": "Authorize \"%s\" to access your account?",
"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.",
+20 -30
View File
@@ -405,37 +405,27 @@ func CommonRoutes() *web.Router {
}, reqPackageAccess(perm.AccessModeRead))
})
r.Group("/npm", func() {
// HINT: NPM-ROUTE-PATH-PATTERN: search this keyword to see more details
scopeRegexp := `^@` + npm_module.RegexpNamePart + `$`
idRegexp := `^(@` + npm_module.RegexpNamePart + `%2[fF])?` + npm_module.RegexpNamePart + `$`
addPackageHandlers := func() {
r.Get("", npm.PackageMetadata)
r.Put("", reqPackageAccess(perm.AccessModeWrite), npm.UploadPackage)
r.Get("/{version}", npm.PackageVersionMetadata)
r.Group("/-/{version}/{filename}", func() {
r.Get("", npm.DownloadPackageFile)
r.Delete("/-rev/{revision}", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackageVersion)
})
r.Get("/-/{filename}", npm.DownloadPackageFileByName)
r.Group("/-rev/{revision}", func() {
r.Delete("", npm.DeletePackage)
r.Put("", npm.DeletePreview)
}, reqPackageAccess(perm.AccessModeWrite))
}
r.Group("/{scope:"+scopeRegexp+"}/{id:"+idRegexp+"}", addPackageHandlers)
r.Group("/{id:"+idRegexp+"}", addPackageHandlers)
r.Get("/-/v1/search", npm.PackageSearch)
r.Get("/-/ping", npm.Ping)
r.Get("/-/whoami", npm.Whoami)
r.PathGroup("/*", func(g *web.RouterPathGroup) {
// HINT: NPM-ROUTE-PATH-PATTERN: search this keyword to see more details
packageId := `/<id:(@` + npm_module.RegexpNamePart + `/)?` + npm_module.RegexpNamePart + ">"
g.UseUnescapedPath()
g.MatchPath("DELETE", packageId+"/-/<version>/<filename>/-rev/<revision>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackageVersion)
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("DELETE", packageId+"/-rev/<revision>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackage)
g.MatchPath("PUT", packageId+"/-rev/<revision>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePreview)
g.MatchPath("GET", packageId+"/<version>", npm.PackageVersionMetadata)
g.MatchPath("GET", packageId, npm.PackageMetadata)
g.MatchPath("PUT", packageId, reqPackageAccess(perm.AccessModeWrite), npm.UploadPackage)
addPackageDistTagsHandlers := func() {
r.Get("", npm.ListPackageTags)
r.Group("/{tag}", func() {
r.Put("", npm.AddPackageTag)
r.Delete("", npm.DeletePackageTag)
}, reqPackageAccess(perm.AccessModeWrite))
}
r.Group("/-/package/{scope:"+scopeRegexp+"}/{id:"+idRegexp+"}/dist-tags", addPackageDistTagsHandlers)
r.Group("/-/package/{id:"+idRegexp+"}/dist-tags", addPackageDistTagsHandlers)
r.Group("/-/v1/search", func() {
r.Get("", npm.PackageSearch)
packageDistTags := "/-/package" + packageId + "/dist-tags"
g.MatchPath("GET", packageDistTags, npm.ListPackageTags)
g.MatchPath("PUT", packageDistTags+"/<tag>", reqPackageAccess(perm.AccessModeWrite), npm.AddPackageTag)
g.MatchPath("DELETE", packageDistTags+"/<tag>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackageTag)
})
}, reqPackageAccess(perm.AccessModeRead))
r.Group("/pub", func() {
+17 -4
View File
@@ -8,7 +8,7 @@ import (
"encoding/base64"
"encoding/hex"
"fmt"
"net/url"
"slices"
"sort"
"time"
@@ -25,6 +25,7 @@ func createPackageMetadataResponse(registryURL string, pds []*packages_model.Pac
distTags := make(map[string]string)
times := make(map[string]time.Time)
firstPublished, lastPublished := pds[0].Version.CreatedUnix, pds[0].Version.CreatedUnix
var latest *packages_model.PackageDescriptor
for _, pd := range pds {
semVer := pd.SemVer.String()
versions[semVer] = createPackageMetadataVersion(registryURL, pd)
@@ -35,6 +36,9 @@ func createPackageMetadataResponse(registryURL string, pds []*packages_model.Pac
for _, pvp := range pd.VersionProperties {
if pvp.Name == npm_module.TagProperty {
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["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)
@@ -86,13 +99,13 @@ func createPackageMetadataVersion(registryURL string, pd *packages_model.Package
PeerDependencies: metadata.PeerDependencies,
PeerDependenciesMeta: metadata.PeerDependenciesMeta,
OptionalDependencies: metadata.OptionalDependencies,
Readme: metadata.Readme,
Bin: metadata.Bin,
HasInstallScript: metadata.HasInstallScript,
HasShrinkwrap: metadata.HasShrinkwrap,
Engines: metadata.Engines,
CPU: metadata.CPU,
OS: metadata.OS,
Libc: metadata.Libc,
Directories: metadata.Directories,
Funding: metadata.Funding,
AcceptDependencies: metadata.AcceptDependencies,
@@ -100,7 +113,7 @@ func createPackageMetadataVersion(registryURL string, pd *packages_model.Package
Dist: npm_module.PackageDistribution{
Shasum: pd.Files[0].Blob.HashSHA1,
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"},
Version: &packages_model.PackageVersion{Version: v, CreatedUnix: timeutil.TimeStamp(publishedUnix)},
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{{
File: &packages_model.PackageFile{LowerName: "test-" + v + ".tgz"},
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{
descriptor("1.1.0", 1000, npm_module.Repository{}),
descriptor("2.0.0-rc.1", 1500, repository),
descriptor("1.0.0", 2000, repository),
})
assert.Equal(t, map[string]time.Time{
"1.0.0": time.Unix(2000, 0).UTC(),
"1.1.0": time.Unix(1000, 0).UTC(),
"created": time.Unix(1000, 0).UTC(),
"modified": time.Unix(2000, 0).UTC(),
"1.0.0": time.Unix(2000, 0).UTC(),
"1.1.0": time.Unix(1000, 0).UTC(),
"2.0.0-rc.1": time.Unix(1500, 0).UTC(),
"created": time.Unix(1000, 0).UTC(),
"modified": time.Unix(2000, 0).UTC(),
}, 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, []string{"gitea"}, result.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,
"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,
)
assert.Equal(t, repository, result.Versions["1.0.0"].Repository)
+90 -104
View File
@@ -6,6 +6,7 @@ package npm
import (
"bytes"
std_ctx "context"
"crypto/sha256"
"errors"
"fmt"
"io"
@@ -44,49 +45,53 @@ func apiError(ctx *context.Context, status int, obj any) {
// packageNameFromParams gets the package name from the url parameters
func packageNameFromParams(ctx *context.Context) string {
// Real examples: these 2 both should work:
// HINT: NPM-ROUTE-PATH-PATTERN: real examples: these cases all should work:
// * "https://registry.npmjs.org/@angular/core"
// * "https://registry.npmjs.org/@angular%2Fcore"
// * "https://registry.npmjs.org/%40angular%2Fcore"
//
// HINT: NPM-ROUTE-PATH-PATTERN: The cases for the path parameters:
// * ".../TheName/...": id="TheName"
// * ".../@TheScope/TheName/...": scope="@TheScope", id="TheName"
// * ".../@TheScope%2FTheName/...": id="@TheScope/TheName"
scope := ctx.PathParam("scope")
fullOrSub := ctx.PathParam("id") // may be a full name or a subpath of the full package name
if scope != "" {
// now id is the subpath of the full package name, e.g. "core" in "@angular/core"
return fmt.Sprintf("%s/%s", scope, fullOrSub)
}
return fullOrSub // id is the full package name, e.g.: "@angular/core" or "lodash"
return ctx.PathParam("id") // id is the full package name, e.g.: "@angular/core" or "lodash"
}
func buildNpmRegistryURL(ctx std_ctx.Context, owner *user_model.User) string {
return httplib.GuessCurrentAppURL(ctx) + "api/packages/" + url.PathEscape(owner.Name) + "/npm"
}
// PackageMetadata returns the metadata for a single package
func PackageMetadata(ctx *context.Context) {
packageName := packageNameFromParams(ctx)
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNpm, packageName)
func packageMetadata(ctx *context.Context) *npm_module.PackageMetadata {
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNpm, packageNameFromParams(ctx))
if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
return nil
}
if len(pvs) == 0 {
apiError(ctx, http.StatusNotFound, err)
return
return nil
}
pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
return nil
}
resp := createPackageMetadataResponse(buildNpmRegistryURL(ctx, ctx.Package.Owner), pds)
ctx.JSON(http.StatusOK, resp)
return createPackageMetadataResponse(buildNpmRegistryURL(ctx, ctx.Package.Owner), pds)
}
// 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
@@ -110,7 +115,11 @@ func PackageVersionMetadata(ctx *context.Context) {
return
}
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
}
@@ -120,25 +129,40 @@ func PackageVersionMetadata(ctx *context.Context) {
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 DownloadPackageFile(ctx *context.Context) {
packageName := packageNameFromParams(ctx)
packageVersion := ctx.PathParam("version")
filename := ctx.PathParam("filename")
func packageVersionByFilename(ctx *context.Context) *packages_model.PackageVersion {
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: 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,
&packages_service.PackageInfo{
Owner: ctx.Package.Owner,
PackageType: packages_model.TypeNpm,
Name: packageName,
Version: packageVersion,
},
pv,
&packages_service.PackageFileInfo{
Filename: filename,
Filename: ctx.PathParam("filename"),
},
ctx.Req.Method,
)
@@ -150,54 +174,14 @@ func DownloadPackageFile(ctx *context.Context) {
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
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 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)
} else {
apiError(ctx, http.StatusInternalServerError, err)
@@ -350,26 +334,14 @@ func deprecatePackage(ctx *context.Context, dep *npm_module.PackageDeprecation)
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) {
packageName := packageNameFromParams(ctx)
packageVersion := ctx.PathParam("version")
pv := packageVersionByFilename(ctx)
if pv == nil {
return
}
err := packages_service.RemovePackageVersionByNameAndVersion(
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
}
if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pv); err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
}
@@ -404,9 +376,7 @@ func DeletePackage(ctx *context.Context) {
// ListPackageTags returns all tags for a package
func ListPackageTags(ctx *context.Context) {
packageName := packageNameFromParams(ctx)
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNpm, packageName)
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNpm, packageNameFromParams(ctx))
if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
@@ -424,7 +394,11 @@ func ListPackageTags(ctx *context.Context) {
}
}
ctx.JSON(http.StatusOK, tags)
if _, ok := tags["latest"]; ok {
ctx.JSON(http.StatusOK, tags)
} else if metadata := packageMetadata(ctx); metadata != nil { // unset, so list the packument's fallback
ctx.JSON(http.StatusOK, metadata.DistTags)
}
}
// AddPackageTag adds a tag to the package
@@ -534,6 +508,18 @@ func setPackageTag(ctx std_ctx.Context, tag string, pv *packages_model.PackageVe
})
}
func Ping(ctx *context.Context) {
ctx.JSON(http.StatusOK, map[string]any{})
}
func Whoami(ctx *context.Context) {
if ctx.Doer == nil {
apiError(ctx, http.StatusUnauthorized, "Unauthorized")
return
}
ctx.JSON(http.StatusOK, map[string]string{"username": ctx.Doer.Name})
}
func PackageSearch(ctx *context.Context) {
pvs, total, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{
OwnerID: ctx.Package.Owner.ID,
+18 -34
View File
@@ -76,6 +76,7 @@ import (
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
user_model "gitea.dev/models/user"
"gitea.dev/modules/httplib"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
@@ -935,31 +936,22 @@ func apiAuth(authMethod auth.Method) func(*context.APIContext) {
}
}
// verifyAuthWithOptions checks authentication according to options
func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.APIContext) {
// verifyAuthWithOptionsAPI checks authentication according to options
func verifyAuthWithOptionsAPI(options *common.VerifyOptions) func(ctx *context.APIContext) {
return func(ctx *context.APIContext) {
// Check prohibit login users.
if ctx.IsSigned {
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.",
})
check := common.CheckSignedInUser(ctx.Doer, nil)
if check.NeedActivateAccount {
ctx.JSON(http.StatusForbidden, map[string]string{"message": "This account is not activated."})
return
}
if !ctx.Doer.IsActive || ctx.Doer.ProhibitLogin {
} else if check.LoginIsProhibited {
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
}
if ctx.Doer.MustChangePassword {
ctx.JSON(http.StatusForbidden, map[string]string{
"message": "You must change your password. Change it at: " + setting.AppURL + "/user/change_password",
})
} else if check.NeedChangePassword {
msg := "You must change your password. Change it at: " + httplib.MakeAbsoluteURL(ctx, setting.AppSubURL+"/user/settings/change_password")
ctx.JSON(http.StatusForbidden, map[string]string{"message": msg})
return
}
}
@@ -970,20 +962,12 @@ func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.APIC
return
}
if options.SignInRequired {
if !ctx.IsSigned {
// Restrict API calls with error message.
ctx.JSON(http.StatusForbidden, map[string]string{
"message": "Only signed in user is allowed to call APIs.",
})
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.SignInRequired && !ctx.IsSigned {
// Restrict API calls with error message.
ctx.JSON(http.StatusForbidden, map[string]string{
"message": "Only signed in user is allowed to call APIs.",
})
return
}
if options.AdminRequired {
@@ -1035,7 +1019,7 @@ func Routes() *web.Router {
// Get user from session if logged in.
m.AfterRouting(apiAuth(buildAuthGroup()))
m.AfterRouting(verifyAuthWithOptions(&common.VerifyOptions{
m.AfterRouting(verifyAuthWithOptionsAPI(&common.VerifyOptions{
SignInRequired: setting.Service.RequireSignInViewStrict,
}))
+3 -3
View File
@@ -162,7 +162,7 @@ func GetRawFileOrLFS(ctx *context.APIContext) {
// if it's not a pointer, just serve the data directly
if !pointer.IsValid() {
_, _ = ctx.Resp.Write(lfsPointerBuf)
httplib.ServeUserContentByReader(ctx.Req, ctx.Resp, int64(len(lfsPointerBuf)), bytes.NewReader(lfsPointerBuf), httplib.ServeHeaderOptions{Filename: blob.Name()})
return
}
@@ -171,7 +171,7 @@ func GetRawFileOrLFS(ctx *context.APIContext) {
// If there isn't one, just serve the data directly
if errors.Is(err, git_model.ErrLFSObjectNotExist) {
_, _ = ctx.Resp.Write(lfsPointerBuf)
httplib.ServeUserContentByReader(ctx.Req, ctx.Resp, int64(len(lfsPointerBuf)), bytes.NewReader(lfsPointerBuf), httplib.ServeHeaderOptions{Filename: blob.Name()})
return
} else if err != nil {
ctx.APIErrorInternal(err)
@@ -198,7 +198,7 @@ func GetRawFileOrLFS(ctx *context.APIContext) {
return
}
defer lfsDataFile.Close()
httplib.ServeUserContentByFile(ctx.Base.Req, ctx.Base.Resp, lfsDataFile, httplib.ServeHeaderOptions{Filename: ctx.Repo.TreePath})
httplib.ServeUserContentByFile(ctx.Base.Req, ctx.Base.Resp, lfsDataFile, httplib.ServeHeaderOptions{Filename: blob.Name()})
}
func getBlobForEntry(ctx *context.APIContext) (blob *git.Blob, entry *git.TreeEntry, lastModified *time.Time) {
+2 -2
View File
@@ -386,8 +386,8 @@ func attachmentBelongsToRepoOrIssue(ctx *context.APIContext, attachment *repo_mo
ctx.APIErrorNotFound("no such attachment in repo")
return false
}
if attachment.IssueID == 0 {
log.Debug("Requested attachment[%d] is not in an issue.", attachment.ID)
if attachment.IssueID == 0 || attachment.CommentID != 0 {
log.Debug("Requested attachment[%d] is not an issue attachment.", attachment.ID)
ctx.APIErrorNotFound("no such attachment in issue")
return false
} else if issue != nil && attachment.IssueID != issue.ID {
+6 -1
View File
@@ -291,6 +291,11 @@ func AddPushMirror(ctx *context.APIContext) {
return
}
if setting.Mirror.DisableNewPush {
ctx.APIError(http.StatusForbidden, "the site administrator has disabled the creation of new push mirrors")
return
}
pushMirror := web.GetForm[*api.CreatePushMirrorOption](ctx)
CreatePushMirror(ctx, pushMirror)
}
@@ -356,7 +361,7 @@ func CreatePushMirror(ctx *context.APIContext, mirrorOption *api.CreatePushMirro
address, err := git.ParseRemoteAddr(mirrorOption.RemoteAddress, mirrorOption.RemoteUsername, mirrorOption.RemotePassword)
if err == nil {
err = migrations.IsMigrateURLAllowed(address, ctx.ContextUser)
err = migrations.IsMigrateURLAllowed(address, ctx.Doer)
}
if err != nil {
HandleRemoteAddressError(ctx, err)
+23
View File
@@ -10,13 +10,36 @@ import (
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/test"
"gitea.dev/services/contexttest"
"github.com/stretchr/testify/assert"
)
func TestCreatePushMirrorUsesCallerPermission(t *testing.T) {
defer test.MockVariableValue(&setting.ImportLocalPaths, true)()
ctx, resp := contexttest.MockAPIContext(t, "user2/repo1")
ctx.Doer = &user_model.User{}
ctx.ContextUser = &user_model.User{AllowImportLocal: true}
CreatePushMirror(ctx, &api.CreatePushMirrorOption{RemoteAddress: "local-mirror", Interval: "0"})
assert.Equal(t, http.StatusUnauthorized, resp.Code)
}
func TestAddPushMirrorDisabled(t *testing.T) {
defer test.MockVariableValue(&setting.Mirror.DisableNewPush, true)()
ctx, resp := contexttest.MockAPIContext(t, "user2/repo1")
AddPushMirror(ctx)
assert.Equal(t, http.StatusForbidden, resp.Code)
assert.Contains(t, resp.Body.String(), "the site administrator has disabled the creation of new push mirrors")
}
// TestPushMirrorSync verifies the endpoint attempts every push mirror instead
// of aborting on the first failure, reporting all failed remotes with a 422.
// Each remote name is not a configured git remote, so SyncPushMirror fails fast
+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) {
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 {
+15
View File
@@ -6,6 +6,8 @@ package common
import (
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
"gitea.dev/modules/session"
"gitea.dev/modules/setting"
"gitea.dev/modules/web/middleware"
auth_service "gitea.dev/services/auth"
"gitea.dev/services/context"
@@ -56,3 +58,16 @@ type VerifyOptions struct {
AdminRequired 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 -1
View File
@@ -139,7 +139,8 @@ func hookPostReceiveUpdateRepoByOptions(ctx *gitea_context.PrivateContext, opts
// The repo is empty and being initialized by this push, so there is no
// dependent state (webhooks, notifications, visibility fan-out) to reconcile
// yet; setting the flags directly is sufficient in this push-to-create case.
if isPrivate.Has() && repo.IsPrivate != isPrivate.Value() {
if isPrivate.Has() && repo.IsPrivate != isPrivate.Value() &&
(isPrivate.Value() || !setting.Repository.ForcePrivate || ctx.Doer.IsAdmin) {
repo.IsPrivate = isPrivate.Value()
if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_private"); err != nil {
log.Error("failed to update repo is_private: %v", err)
+2 -2
View File
@@ -229,9 +229,9 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
}
} else {
if isForcePush {
canPush = !changedProtectedfiles && protectBranch.CanUserForcePush(ctx, ctx.Doer)
canPush = !changedProtectedfiles && protectBranch.CanUserForcePush(ctx, ctx.Doer, ctx.Repo.Permission)
} 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 (
"testing"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/git"
"gitea.dev/modules/private"
"gitea.dev/services/contexttest"
"github.com/stretchr/testify/assert"
"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
// 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
+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),
// which clears AccessToken/RefreshToken/ExpiresAt on the ExternalLoginUser row
// 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 {
extLogin, hasExt, err := user_model.GetExternalLogin(ctx, authSource.ID, gothUser.UserID)
if err != nil {
+18 -8
View File
@@ -11,6 +11,7 @@ import (
"net/http"
"net/url"
"strconv"
"strings"
audit_model "gitea.dev/models/audit"
"gitea.dev/models/auth"
@@ -20,6 +21,7 @@ import (
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/util"
"gitea.dev/modules/web"
"gitea.dev/services/audit"
auth_service "gitea.dev/services/auth"
@@ -321,9 +323,18 @@ func AuthorizeOAuth(ctx *context.Context) {
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
// 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)
if err != nil {
handleServerError(ctx, form.State, form.RedirectURI)
@@ -347,6 +358,7 @@ func AuthorizeOAuth(ctx *context.Context) {
// check if additional scopes
ctx.Data["AdditionalScopes"] = oauth2_provider.GrantAdditionalScopes(form.Scope) != auth.AccessTokenScopeAll
ctx.Data["AddedScopes"] = addedScopes
// show authorize page to grant access
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)
} else if grant.Scope != form.Scope {
handleAuthorizeError(ctx, AuthorizeError{
State: form.State,
ErrorDescription: "a grant exists with different scope",
ErrorCode: ErrorCodeServerError,
}, form.RedirectURI)
return
if err := auth.UpdateGrantScope(ctx, grant, form.Scope); err != nil {
handleServerError(ctx, form.State, form.RedirectURI)
return
}
}
if len(form.Nonce) > 0 {
@@ -576,7 +586,7 @@ func handleRefreshToken(ctx *context.Context, form forms.AccessTokenForm, server
}
token, err := oauth2_provider.ParseToken(form.RefreshToken, serverKey)
if err != nil {
if err != nil || token.Kind != oauth2_provider.KindRefreshToken {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeUnauthorizedClient,
ErrorDescription: "unable to parse refresh token",
+28
View File
@@ -13,6 +13,10 @@ import (
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"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"
"github.com/golang-jwt/jwt/v5"
@@ -105,3 +109,27 @@ func TestOAuth2AvatarClientBlocksCloudMetadata(t *testing.T) {
assert.ErrorIs(t, err, policy.ErrDenied,
"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/timeutil"
"gitea.dev/modules/web"
"gitea.dev/routers/common"
"gitea.dev/services/audit"
"gitea.dev/services/context"
"gitea.dev/services/forms"
@@ -282,10 +283,9 @@ func MustChangePasswordPost(ctx *context.Context) {
return
}
// Make sure only requests for users who are eligible to change their password via
// this method passes through
if !ctx.Doer.MustChangePassword {
ctx.ServerError("MustUpdatePassword", errors.New("cannot update password. Please visit the settings page"))
if !common.CheckSignedInUser(ctx.Doer, ctx.Session).NeedChangePassword {
log.Debug("User %s attempted to access the must change password page, but they are not required to change their password", ctx.Doer.Name)
ctx.NotFound(nil)
return
}
+10 -1
View File
@@ -9,7 +9,9 @@ import (
activities_model "gitea.dev/models/activities"
"gitea.dev/models/organization"
"gitea.dev/models/renderhelper"
user_model "gitea.dev/models/user"
"gitea.dev/modules/markup/markdown"
"gitea.dev/modules/setting"
"gitea.dev/services/context"
feed_service "gitea.dev/services/feed"
@@ -28,8 +30,15 @@ func ShowUserFeedAtom(ctx *context.Context) {
// showUserFeed show user activity as RSS / Atom feed
func showUserFeed(ctx *context.Context, formatType string) {
includePrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == ctx.ContextUser.ID)
isOrganisation := ctx.ContextUser.IsOrganization()
if !setting.Other.EnableFeed ||
isOrganisation && !organization.HasOrgOrUserVisible(ctx, ctx.ContextUser, ctx.Doer) ||
!isOrganisation && !user_model.IsUserVisibleToViewer(ctx, ctx.ContextUser, ctx.Doer) {
ctx.NotFound(nil)
return
}
includePrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == ctx.ContextUser.ID)
if ctx.IsSigned && isOrganisation && !includePrivate {
// When feed is requested by a member of the organization,
// include the private repo's the member has access to.
+3 -23
View File
@@ -17,38 +17,18 @@ import (
"gitea.dev/modules/sitemap"
"gitea.dev/modules/structs"
"gitea.dev/modules/templates"
"gitea.dev/modules/web/middleware"
"gitea.dev/routers/web/auth"
"gitea.dev/routers/web/user"
"gitea.dev/services/context"
)
const (
// tplHome home page template
tplHome templates.TplName = "home"
)
const tplHome templates.TplName = "home"
// Home render home page
func Home(ctx *context.Context) {
if ctx.IsSigned {
if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm {
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)
}
user.Dashboard(ctx)
return
// Check non-logged users landing page.
} else if setting.LandingPageURL != setting.LandingPageHome {
// Check non-logged users landing page
ctx.Redirect(setting.AppSubURL + string(setting.LandingPageURL))
return
}
+9 -1
View File
@@ -642,7 +642,15 @@ func (data *actionRunListData) preparePartialRefreshRuns(ctx *context.Context) b
ctx.ServerError("GetRunsByRepoAndID", err)
return false
}
data.ActionRuns = runs
runsMap := make(map[int64]*actions_model.ActionRun, len(runs))
for _, run := range runs {
runsMap[run.ID] = run
}
for _, id := range data.refreshRunIDs {
if run, ok := runsMap[id]; ok {
data.ActionRuns = append(data.ActionRuns, run)
}
}
return true
}
+15
View File
@@ -15,6 +15,7 @@ import (
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
web_context "gitea.dev/services/context"
"gitea.dev/services/contexttest"
"github.com/stretchr/testify/assert"
)
@@ -74,3 +75,17 @@ func newWorkflowBadgeTestContext(t *testing.T) *web_context.Context {
}
return ctx
}
func TestActionRunListData(t *testing.T) {
unittest.PrepareTestEnv(t)
t.Run("preparePartialRefreshRuns", func(t *testing.T) {
ctx, _ := contexttest.MockContext(t, "user5/repo4/actions")
contexttest.LoadRepo(t, ctx, 4)
d := &actionRunListData{refreshRunIDs: []int64{791, 792}}
d.preparePartialRefreshRuns(ctx)
assert.Equal(t, []int64{791, 792}, []int64{d.ActionRuns[0].ID, d.ActionRuns[1].ID})
d = &actionRunListData{refreshRunIDs: []int64{792, 791}}
d.preparePartialRefreshRuns(ctx)
assert.Equal(t, []int64{792, 791}, []int64{d.ActionRuns[0].ID, d.ActionRuns[1].ID})
})
}
+1 -1
View File
@@ -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) {
ctx.JSONError(ctx.Tr("repo.pulls.invalid_merge_option"))
} else if conflictError, ok := err.(pull_service.ErrMergeConflicts); ok {
+5
View File
@@ -660,6 +660,11 @@ func deleteReleaseOrTag(ctx *context.Context, isDelTag bool) {
return
}
if isDelTag && !rel.IsTag {
ctx.HTTPError(http.StatusConflict, "a tag attached to a release cannot be deleted directly")
return
}
if err := release_service.DeleteReleaseByID(ctx, ctx.Repo.Repository, rel, ctx.Doer, isDelTag); err != nil {
if release_service.IsErrProtectedTagName(err) {
ctx.Flash.Error(ctx.Tr("repo.release.tag_name_protected"))
+16
View File
@@ -4,6 +4,7 @@
package repo
import (
"net/http"
"net/http/httptest"
"testing"
@@ -21,6 +22,21 @@ import (
"github.com/stretchr/testify/require"
)
func TestDeleteTagRetainsReleaseAndAttachments(t *testing.T) {
unittest.PrepareTestEnv(t)
ctx, resp := contexttest.MockContext(t, "POST user2/repo1/tags/delete?id=1")
contexttest.LoadUser(t, ctx, 2)
contexttest.LoadRepo(t, ctx, 1)
release := unittest.AssertExistsAndLoadBean(t, &repo_model.Release{ID: 1})
attachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 9, ReleaseID: 1})
DeleteTag(ctx)
assert.Equal(t, http.StatusConflict, resp.Code)
assert.Equal(t, release, unittest.AssertExistsAndLoadBean(t, &repo_model.Release{ID: 1}))
assert.Equal(t, attachment, unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 9}))
}
func TestNewReleasePost(t *testing.T) {
unittest.PrepareTestEnv(t)
-8
View File
@@ -734,18 +734,10 @@ func UsernameSubRoute(ctx *context.Context) {
ShowGPGKeys(ctx)
}
case strings.HasSuffix(username, ".rss"):
if !setting.Other.EnableFeed {
ctx.HTTPError(http.StatusNotFound)
return
}
if reloadParam(".rss") {
feed.ShowUserFeedRSS(ctx)
}
case strings.HasSuffix(username, ".atom"):
if !setting.Other.EnableFeed {
ctx.HTTPError(http.StatusNotFound)
return
}
if reloadParam(".atom") {
feed.ShowUserFeedAtom(ctx)
}
+7
View File
@@ -322,6 +322,13 @@ func prepareUserProfileTabData(ctx *context.Context, profileDbRepo *repo_model.R
// ActionUserFollow is for follow/unfollow user request
func ActionUserFollow(ctx *context.Context) {
isOrg := ctx.ContextUser.IsOrganization()
if isOrg && !organization.HasOrgOrUserVisible(ctx, ctx.ContextUser, ctx.Doer) ||
!isOrg && !user_model.IsUserVisibleToViewer(ctx, ctx.ContextUser, ctx.Doer) {
ctx.NotFound(nil)
return
}
var err error
switch ctx.FormString("action") {
case "follow":
+18 -32
View File
@@ -174,38 +174,29 @@ func newWebAuthMiddleware() *AuthMiddleware {
return webAuth
}
func doerMustChangePassword(ctx *context.Context) bool {
// an impersonating admin must not be forced to set the impersonated user's password
return ctx.Doer != nil && ctx.Doer.MustChangePassword && !ctx.DoerIsImpersonated()
}
// verifyAuthWithOptions checks authentication according to options
func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.Context) {
// verifyAuthWithOptionsWeb checks authentication according to options
func verifyAuthWithOptionsWeb(options *common.VerifyOptions) func(ctx *context.Context) {
crossOriginProtection := http.NewCrossOriginProtection()
return func(ctx *context.Context) {
// Check prohibit login users.
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.HTML(http.StatusOK, "user/auth/activate")
return
}
if !ctx.Doer.IsActive || ctx.Doer.ProhibitLogin {
} else if check.LoginIsProhibited {
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")
return
}
if doerMustChangePassword(ctx) {
} else if check.NeedChangePassword {
if ctx.Req.URL.Path != "/user/settings/change_password" {
if strings.HasPrefix(ctx.Req.UserAgent(), "git") {
ctx.HTTPError(http.StatusUnauthorized, ctx.Locale.TrString("auth.must_change_password"))
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())
ctx.Redirect(setting.AppSubURL + "/user/settings/change_password")
return
@@ -230,15 +221,9 @@ func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.Cont
}
}
if options.SignInRequired {
if !ctx.IsSigned {
ctx.Redirect(middleware.RedirectLinkUserLogin(ctx.Req))
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
}
if options.SignInRequired && !ctx.IsSigned {
ctx.Redirect(middleware.RedirectLinkUserLogin(ctx.Req))
return
}
// 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.
// - For non-browser client requests: git clone via http, no Sec-Fetch-Site header.
// 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
// repository and owner mount points.
@@ -355,13 +340,14 @@ func addProjectBoardRoutes(m *web.Router) {
// registerWebRoutes register routes
func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
// middleware: required to be signed in or signed out
reqSignIn := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: true})
reqSignOut := verifyAuthWithOptions(&common.VerifyOptions{SignOutRequired: true})
reqSignIn := verifyAuthWithOptionsWeb(&common.VerifyOptions{SignInRequired: true})
reqSignOut := verifyAuthWithOptionsWeb(&common.VerifyOptions{SignOutRequired: true})
// middleware: optional sign in (if signed in, use the user as doer, if not, no doer)
optSignIn := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: setting.Service.RequireSignInViewStrict})
optExploreSignIn := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: setting.Service.RequireSignInViewStrict || setting.Service.Explore.RequireSigninView})
optSignInHome := verifyAuthWithOptionsWeb(&common.VerifyOptions{SignInRequired: false}) // site home doesn't need "require sign-in" protection
optSignIn := verifyAuthWithOptionsWeb(&common.VerifyOptions{SignInRequired: setting.Service.RequireSignInViewStrict})
optExploreSignIn := verifyAuthWithOptionsWeb(&common.VerifyOptions{SignInRequired: setting.Service.RequireSignInViewStrict || setting.Service.Explore.RequireSigninView})
// middleware: only apply CrossOriginProtection
crossOriginProtect := verifyAuthWithOptions(&common.VerifyOptions{DisableCrossOriginProtection: false})
crossOriginProtect := verifyAuthWithOptionsWeb(&common.VerifyOptions{DisableCrossOriginProtection: false})
openIDSignInEnabled := func(ctx *context.Context) {
if !setting.Service.EnableOpenIDSignIn {
@@ -533,7 +519,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
// FIXME: not all routes need go through same middleware.
// 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.Group("/.well-known", func() {
m.Get("/openid-configuration", auth.OIDCWellKnown)
@@ -780,7 +766,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Get("/avatar/{hash}", user.AvatarByEmailHash)
adminReq := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: true, AdminRequired: true})
adminReq := verifyAuthWithOptionsWeb(&common.VerifyOptions{SignInRequired: true, AdminRequired: true})
// ***** START: Admin *****
m.Group("/-/admin", func() {
+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
// 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) {
return errors.Join(errSkipAutoMerge, err)
}
-5
View File
@@ -211,11 +211,6 @@ func (ctx *Context) DoerNeedTwoFactorAuth() bool {
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.
// 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.
+1 -1
View File
@@ -69,7 +69,7 @@ func (c TemplateContext) CurrentWebTheme() *webtheme.ThemeMetaInfo {
func (c TemplateContext) ImpersonatedUser() *user_model.User {
webCtx := GetWebContext(c)
if webCtx == nil || webCtx.Doer == nil || !webCtx.DoerIsImpersonated() {
if webCtx == nil || webCtx.Doer == nil || !IsDoerSessionImpersonated(webCtx.Session) {
return nil
}
return webCtx.Doer
+1 -1
View File
@@ -190,7 +190,7 @@ func PrepareCommitFormOptions(ctx *Context, doer *user_model.User, targetRepo *r
protectionRequireSigned := false
if protectedBranch != nil {
protectedBranch.Repo = targetRepo
canPushWithProtection = protectedBranch.CanUserPush(ctx, doer)
canPushWithProtection = protectedBranch.CanUserPush(ctx, doer, doerRepoPerm)
protectionRequireSigned = protectedBranch.RequireSignedCommits
// If branch-wide push is restricted, allow direct commit when the
// URL-derived tree path matches an unprotected file pattern. The
+5
View File
@@ -9,6 +9,7 @@ import (
"strings"
user_model "gitea.dev/models/user"
"gitea.dev/modules/session"
)
// 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
}
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
}
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)
}
+2 -3
View File
@@ -107,9 +107,8 @@ func NewGiteaDownloader(ctx context.Context, baseURL, repoPath, username, passwo
if err != nil {
log.Info("Unable to get global API settings. Ignoring these.")
log.Debug("giteaClient.GetGlobalAPISettings. Error: %v", err)
}
if apiConf != nil {
maxPerPage = apiConf.MaxResponseItems
} else if apiConf != nil && apiConf.MaxResponseItems > 0 {
maxPerPage = min(apiConf.MaxResponseItems, 100)
}
return &GiteaDownloader{
+10 -7
View File
@@ -5,6 +5,7 @@ package migrations
import (
"fmt"
"math"
"net/http"
"net/http/httptest"
"os"
@@ -317,15 +318,16 @@ func TestGiteaDownloadRepo(t *testing.T) {
func TestGiteaDownloadCommentsPaging(t *testing.T) {
for _, tc := range []struct {
maxResponseItems, commentCount, requests int
paginated bool
maxResponseItems, pageSize, commentCount, requests int
paginated bool
}{
{maxResponseItems: 2, commentCount: 2, requests: 2},
{maxResponseItems: 2, commentCount: 3, requests: 1},
{maxResponseItems: 2, commentCount: 4, requests: 3, paginated: true},
{maxResponseItems: 0, commentCount: 0, requests: 1},
{maxResponseItems: 2, pageSize: 2, commentCount: 2, requests: 2},
{maxResponseItems: 2, pageSize: 2, commentCount: 3, requests: 1},
{maxResponseItems: 2, pageSize: 2, commentCount: 4, requests: 3, paginated: true},
{maxResponseItems: 0, pageSize: 10, commentCount: 0, requests: 1},
{maxResponseItems: math.MaxInt, pageSize: 100, commentCount: 0, requests: 1},
} {
t.Run(strconv.Itoa(tc.commentCount), func(t *testing.T) {
t.Run(fmt.Sprintf("maxResponseItems=%d/comments=%d", tc.maxResponseItems, tc.commentCount), func(t *testing.T) {
commentRequests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
@@ -352,6 +354,7 @@ func TestGiteaDownloadCommentsPaging(t *testing.T) {
downloader, err := NewGiteaDownloader(t.Context(), server.URL, "o/r", "", "", "")
require.NoError(t, err)
require.Equal(t, tc.pageSize, downloader.maxPerPage)
comments, _, err := downloader.GetComments(t.Context(), &base.Issue{Number: 1})
require.NoError(t, err)
+16 -1
View File
@@ -14,6 +14,7 @@ import (
"strconv"
"strings"
"unicode"
"uuid"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
@@ -27,6 +28,7 @@ import (
"gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/globallock"
"gitea.dev/modules/graceful"
"gitea.dev/modules/gtprof"
"gitea.dev/modules/httplib"
"gitea.dev/modules/log"
"gitea.dev/modules/references"
@@ -289,9 +291,22 @@ func hasPullRequestCommitBeenMerged(ctx context.Context, pr *issues_model.PullRe
// Merge merges pull request to base repository.
// 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, 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 {
pr, err := issues_model.GetPullRequestByID(ctx, prID)
if err != nil {
+2 -2
View File
@@ -123,8 +123,8 @@ func isUserAllowedToPushOrForcePushInRepoBranch(ctx context.Context, user *user_
}
if pb != nil { // override previous results if there is a branch protection rule
pb.Repo = repo
pushAllowed = pb.CanUserPush(ctx, user)
forcePushAllowed = pb.CanUserForcePush(ctx, user)
pushAllowed = pb.CanUserPush(ctx, user, repoPerm)
forcePushAllowed = pb.CanUserForcePush(ctx, user, repoPerm)
}
return pushAllowed, forcePushAllowed, nil
}
+1 -1
View File
@@ -447,7 +447,7 @@ func RenameBranch(ctx context.Context, repo *repo_model.Repository, doer *user_m
if err != nil {
return "", err
}
if rule != nil && !rule.CanUserPush(ctx, doer) {
if rule != nil && !rule.CanUserPush(ctx, doer, perm) {
return "", git_model.ErrBranchIsProtected
}
+6 -1
View File
@@ -9,6 +9,7 @@ import (
"strings"
git_model "gitea.dev/models/git"
"gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/git"
@@ -88,7 +89,11 @@ func (opts *ApplyDiffPatchOptions) Validate(ctx context.Context, repo *repo_mode
}
if protectedBranch != nil {
protectedBranch.Repo = repo
if !protectedBranch.CanUserPush(ctx, doer) {
perm, err := access.GetDoerRepoPermission(ctx, repo, doer)
if err != nil {
return err
}
if !protectedBranch.CanUserPush(ctx, doer, perm) {
return ErrUserCannotCommit{
UserName: doer.LowerName,
}
+6 -1
View File
@@ -13,6 +13,7 @@ import (
"time"
git_model "gitea.dev/models/git"
"gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/git"
@@ -667,7 +668,11 @@ func VerifyBranchProtection(ctx context.Context, repo *repo_model.Repository, gi
protectedBranch.Repo = repo
globUnprotected := protectedBranch.GetUnprotectedFilePatterns()
globProtected := protectedBranch.GetProtectedFilePatterns()
canUserPush := protectedBranch.CanUserPush(ctx, doer)
perm, err := access.GetDoerRepoPermission(ctx, repo, doer)
if err != nil {
return err
}
canUserPush := protectedBranch.CanUserPush(ctx, doer, perm)
for _, treePath := range treePaths {
isUnprotectedFile := false
if len(globUnprotected) != 0 {
+12 -6
View File
@@ -7,20 +7,26 @@ import (
"bufio"
"bytes"
"context"
"strings"
"gitea.dev/modules/git"
"gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/setting"
)
const gitLogGraphFormatSep = "^" // disallowed char in git ref names
// GetCommitGraph return a list of commit (GraphItems) from all branches
func GetCommitGraph(ctx context.Context, gitRepo *git.Repository, page, maxAllowedColors int, hidePRRefs bool, refs, files []string) (*Graph, error) {
format := "DATA:%D|%H|%ad|%h|%s"
if page == 0 {
page = 1
}
format := "DATA:" + strings.Join([]string{
"%D", // ref names without the " (", ")" wrapping.
"%H", // commit hash
"%ad", // author date (format respects --date= option)
"%h", // abbreviated commit hash
"%s", // subject
}, gitLogGraphFormatSep)
page = max(page, 1)
graphCmd := gitcmd.NewCommand("log", "--graph", "--date-order", "--decorate=full")
if hidePRRefs {
@@ -31,7 +37,7 @@ func GetCommitGraph(ctx context.Context, gitRepo *git.Repository, page, maxAllow
graphCmd.AddArguments("--tags", "--branches")
}
graphCmd.AddArguments("-C", "-M", "--date=iso-strict").
graphCmd.AddArguments("--find-copies", "--find-renames", "--date=iso-strict").
AddOptionFormat("-n %d", setting.UI.GraphMaxCommitNum*page).
AddOptionFormat("--pretty=format:%s", format)
+1 -1
View File
@@ -216,7 +216,7 @@ func parseGitTime(timeStr string) time.Time {
// NewCommit creates a new commit from a provided line
func NewCommit(row, column int, line []byte) (*Commit, error) {
data := bytes.SplitN(line, []byte("|"), 5)
data := bytes.SplitN(line, []byte(gitLogGraphFormatSep), 5)
if len(data) < 5 {
return nil, fmt.Errorf("malformed data section on line %d with commit: %s", row, string(line))
}
+3 -3
View File
@@ -35,7 +35,7 @@ func BenchmarkGetCommitGraph(b *testing.B) {
}
func BenchmarkParseCommitString(b *testing.B) {
testString := "* DATA:|4e61bacab44e9b4730e44a6615d04098dd3a8eaf|2016-12-20 21:10:41 +0100|4e61bac|Add route for graph"
testString := "* DATA:^4e61bacab44e9b4730e44a6615d04098dd3a8eaf^2016-12-20 21:10:41 +0100^4e61bac^Add route for graph"
parser := &Parser{}
parser.Reset()
@@ -224,14 +224,14 @@ func TestParseGlyphs(t *testing.T) {
}
func TestCommitStringParsing(t *testing.T) {
dataFirstPart := "* DATA:|4e61bacab44e9b4730e44a6615d04098dd3a8eaf|2016-12-20 21:10:41 +0100|4e61bac|"
dataFirstPart := "* DATA:^4e61bacab44e9b4730e44a6615d04098dd3a8eaf^2016-12-20 21:10:41 +0100^4e61bac^"
tests := []struct {
shouldPass bool
testName string
commitMessage string
}{
{true, "normal", "not a fancy message"},
{true, "extra pipe", "An extra pipe: |"},
{true, "extra sep", "An extra sep"},
{true, "extra 'Data:'", "DATA: might be trouble"},
}
+1 -1
View File
@@ -4,7 +4,7 @@
{{- $activeStopwatch := call $data.GetActiveStopwatch -}}
{{- $notificationUnreadCount := call $data.GetNotificationUnreadCount -}}
{{/* always rendered so a real-time push can reveal it without a reload */}}
<a class="item active-stopwatch{{if not $activeStopwatch}} tw-hidden{{end}} {{$itemExtraClass}}" {{if $activeStopwatch}}href="{{$activeStopwatch.IssueLink}}" data-seconds="{{$activeStopwatch.Seconds}}"{{end}} title="{{ctx.Locale.Tr "active_stopwatch"}}">
<a class="item active-stopwatch{{if not $activeStopwatch}} tw-hidden{{end}} {{$itemExtraClass}}" {{if $activeStopwatch}}data-seconds="{{$activeStopwatch.Seconds}}"{{end}} title="{{ctx.Locale.Tr "active_stopwatch"}}">
<div class="tw-relative flex-text-block">
{{svg "octicon-stopwatch"}}
<span class="header-stopwatch-dot"></span>
@@ -9,18 +9,15 @@
<h3 class="tw-m-0">{{ctx.Locale.Tr "repo.pulls.cmd_instruction_checkout_title"}}</h3>
{{ctx.Locale.Tr "repo.pulls.cmd_instruction_checkout_desc"}}
</div>
{{$localBranch := $pull.HeadBranch}}
{{if ne $pull.HeadRepo.ID $pull.BaseRepo.ID}}
{{$localBranch = print $pull.HeadRepo.OwnerName "-" $pull.HeadBranch}}
{{end}}
{{$args := $pull.GetInstructionsCliArgs}}
<div class="ui secondary segment tw-font-mono">
{{$gitRemoteName := ctx.RootData.SystemConfig.Repository.GitGuideRemoteName.Value ctx}}
{{if eq $pull.Flow 0}}
<div>git fetch -u {{if ne $pull.HeadRepo.ID $pull.BaseRepo.ID}}{{$pull.HeadRepo.HTMLURL ctx}}{{else}}{{$gitRemoteName}}{{end}} {{$pull.HeadBranch}}:{{$localBranch}}</div>
<div>git fetch -u {{if ne $pull.HeadRepo.ID $pull.BaseRepo.ID}}{{$pull.HeadRepo.HTMLURL ctx}}{{else}}{{$gitRemoteName}}{{end}} {{$args.HeadBranchArg}}:{{$args.LocalBranchArg}}</div>
{{else}}
<div>git fetch -u {{$gitRemoteName}} {{$pull.GetGitHeadRefName}}:{{$localBranch}}</div>
<div>git fetch -u {{$gitRemoteName}} {{$pull.GetGitHeadRefName}}:{{$args.LocalBranchArg}}</div>
{{end}}
<div>git checkout {{$localBranch}}</div>
<div>git checkout {{$args.LocalBranchArg}}</div>
</div>
{{if $data.ShowMergeInstructions}}
<div>
@@ -32,32 +29,32 @@
</div>
<div class="ui secondary segment tw-font-mono">
<div data-pull-merge-style="merge">
<div>git checkout {{$pull.BaseBranch}}</div>
<div>git merge --no-ff {{$localBranch}}</div>
<div>git checkout {{$args.BaseBranchArg}}</div>
<div>git merge --no-ff {{$args.LocalBranchArg}}</div>
</div>
<div class="tw-hidden" data-pull-merge-style="rebase">
<div>git checkout {{$pull.BaseBranch}}</div>
<div>git merge --ff-only {{$localBranch}}</div>
<div>git checkout {{$args.BaseBranchArg}}</div>
<div>git merge --ff-only {{$args.LocalBranchArg}}</div>
</div>
<div class="tw-hidden" data-pull-merge-style="rebase-merge">
<div>git checkout {{$localBranch}}</div>
<div>git rebase {{$pull.BaseBranch}}</div>
<div>git checkout {{$pull.BaseBranch}}</div>
<div>git merge --no-ff {{$localBranch}}</div>
<div>git checkout {{$args.LocalBranchArg}}</div>
<div>git rebase {{$args.BaseBranchArg}}</div>
<div>git checkout {{$args.BaseBranchArg}}</div>
<div>git merge --no-ff {{$args.LocalBranchArg}}</div>
</div>
<div class="tw-hidden" data-pull-merge-style="squash">
<div>git checkout {{$pull.BaseBranch}}</div>
<div>git merge --squash {{$localBranch}}</div>
<div>git checkout {{$args.BaseBranchArg}}</div>
<div>git merge --squash {{$args.LocalBranchArg}}</div>
</div>
<div class="tw-hidden" data-pull-merge-style="fast-forward-only">
<div>git checkout {{$pull.BaseBranch}}</div>
<div>git merge --ff-only {{$localBranch}}</div>
<div>git checkout {{$args.BaseBranchArg}}</div>
<div>git merge --ff-only {{$args.LocalBranchArg}}</div>
</div>
<div class="tw-hidden" data-pull-merge-style="manually-merged">
<div>git checkout {{$pull.BaseBranch}}</div>
<div>git merge {{$localBranch}}</div>
<div>git checkout {{$args.BaseBranchArg}}</div>
<div>git merge {{$args.LocalBranchArg}}</div>
</div>
<div>git push {{$gitRemoteName}} {{$pull.BaseBranch}}</div>
<div>git push {{$gitRemoteName}} {{$args.BaseBranchArg}}</div>
</div>
{{end}}
</div>
+1 -1
View File
@@ -1,5 +1,5 @@
{{if .EnableCaptcha}}{{if eq .CaptchaType "image"}}
<div class="inline field tw-text-center">
<div class="inline field tw-text-center" data-global-init="initImageCaptcha">
{{.Captcha.CreateHTML}}
</div>
<div class="required field {{if .Err_Captcha}}error{{end}}">
+1
View File
@@ -12,6 +12,7 @@
{{end}}
{{ctx.Locale.Tr "auth.authorize_application_created_by" .ApplicationCreatorLinkHTML}}<br>
{{ctx.Locale.Tr "auth.authorize_application_with_scopes" (HTMLFormat "<b>%s</b>" .Scope)}}
{{if .AddedScopes}}<br>{{ctx.Locale.Tr "auth.authorize_application_new_scopes" (HTMLFormat "<b>%s</b>" (StringUtils.Join .AddedScopes " "))}}{{end}}
</p>
</div>
<div class="ui attached segment">
+2
View File
@@ -17,6 +17,8 @@ test('create a bot and manage its access token', async ({page, request}) => {
await page.getByRole('row', {name: /^user /}).getByRole('radio', {name: 'Read', exact: true}).check();
await page.getByRole('button', {name: 'Generate Token'}).click();
const token = await page.getByRole('code').textContent();
await page.getByRole('button', {name: 'Copy', exact: true}).click();
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(token);
const response = await request.get('/api/v1/user', {headers: {Authorization: `token ${token}`}});
expect(await response.json()).toMatchObject({login: botName, type: 'Bot'});
@@ -4,16 +4,20 @@
package integration
import (
"context"
"sync"
"testing"
"time"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
"gitea.dev/models/unittest"
"gitea.dev/modules/setting"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"xorm.io/builder"
)
// minimalWorkflowPayload returns the minimal YAML for a single-job workflow with no steps.
@@ -116,3 +120,42 @@ func TestCreateTaskForRunnerConcurrentClaim(t *testing.T) {
assert.NotZero(t, updated.TaskID)
}
}
func prepareWaitingRunJob(t *testing.T) *actions_model.ActionRunJob {
if setting.Database.Type.IsSQLite3() {
t.Skip("SQLite serializes write transactions")
}
job := &actions_model.ActionRunJob{RepoID: 1, Status: actions_model.StatusWaiting, RunsOn: []string{"ubuntu-latest"}}
require.NoError(t, db.Insert(t.Context(), job))
return job
}
func TestCreateTaskForRunnerDuringOpenClaimDoesNotWait(t *testing.T) {
job := prepareWaitingRunJob(t)
require.NoError(t, db.WithTx(t.Context(), func(ctx context.Context) error {
_, err := db.GetEngine(ctx).ID(job.ID).Cols("name").Update(&actions_model.ActionRunJob{Name: "claiming"})
require.NoError(t, err)
pickupCtx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
_, _, err = actions_model.CreateTaskForRunner(pickupCtx, &actions_model.ActionRunner{})
return err
}))
}
func TestClaimRunJobAfterConcurrentCancelUpdatesNothing(t *testing.T) {
job := prepareWaitingRunJob(t)
require.NoError(t, db.WithTx(t.Context(), func(ctx context.Context) error {
claimed, err := actions_model.GetRunJobByRepoAndID(ctx, job.RepoID, job.ID)
require.NoError(t, err)
_, err = db.GetEngine(t.Context()).ID(job.ID).Cols("status").Update(&actions_model.ActionRunJob{Status: actions_model.StatusCancelled})
require.NoError(t, err)
claimed.TaskID, claimed.Status = 1, actions_model.StatusRunning
affected, err := actions_model.UpdateRunJob(ctx, claimed, builder.Eq{"task_id": 0, "status": actions_model.StatusWaiting}, "task_id", "status")
require.NoError(t, err)
assert.Zero(t, affected)
return nil
}))
}
+11
View File
@@ -235,6 +235,17 @@ func TestAdminBotUser(t *testing.T) {
}
})
t.Run("TokenIgnoresMustChangePassword", func(t *testing.T) {
bot := unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "bot-user"})
bot.IsActive, bot.MustChangePassword = true, true
require.NoError(t, user_model.UpdateUserCols(t.Context(), bot, "is_active", "must_change_password"))
token := &auth_model.AccessToken{UID: bot.ID, Name: "git", Scope: auth_model.AccessTokenScopeAll}
require.NoError(t, auth_model.NewAccessToken(t.Context(), token))
MakeRequest(t, NewRequest(t, "GET", "/api/v1/repos/user2/repo1").AddTokenAuth(token.Token), http.StatusOK)
MakeRequest(t, NewRequest(t, "GET", "/user2/repo1.git/info/refs?service=git-upload-pack").AddBasicAuth(bot.Name, token.Token), http.StatusOK)
})
t.Run("APIRejectsAuthSource", func(t *testing.T) {
bot := unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "bot-user"})
req := NewRequestWithJSON(t, "PATCH", "/api/v1/admin/users/"+bot.Name, map[string]any{"source_id": 1}).AddBasicAuth("user1")
@@ -38,6 +38,11 @@ func TestAPIGetIssueAttachment(t *testing.T) {
apiAttachment := DecodeJSON(t, resp, &api.Attachment{})
unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, IssueID: issue.ID})
commentAttachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 3, RepoID: repo.ID})
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets/%d", repoOwner.Name, repo.Name, unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: commentAttachment.IssueID}).Index, commentAttachment.ID)).
AddTokenAuth(token)
session.MakeRequest(t, req, http.StatusNotFound)
}
func TestAPIListIssueAttachments(t *testing.T) {
+27 -21
View File
@@ -104,6 +104,7 @@ func TestPackageNpm(t *testing.T) {
},
"cpu": ["x64", "arm64"],
"os": ["linux", "darwin"],
"libc": ["glibc"],
"directories": {
"doc": "./doc",
"man": "./man"
@@ -170,8 +171,9 @@ func TestPackageNpm(t *testing.T) {
defer tests.PrintCurrentTest(t)()
rootPaths := []string{
fmt.Sprintf("/api/packages/%s/npm/@scope/test-package", user.Name),
fmt.Sprintf("/api/packages/%s/npm/@scope%%2ftest-package", user.Name),
"/api/packages/user2/npm/@scope/test-package",
"/api/packages/user2/npm/@scope%2Ftest-package",
"/api/packages/user2/npm/%40scope%2ftest-package",
}
for _, root := range rootPaths {
req := NewRequest(t, "GET", fmt.Sprintf("%s/-/%s/%s", root, packageVersion, filename)).AddTokenAuth(token)
@@ -186,7 +188,7 @@ func TestPackageNpm(t *testing.T) {
pvs, err := packages.GetVersionsByPackageType(t.Context(), user.ID, packages.TypeNpm)
assert.NoError(t, err)
assert.Len(t, pvs, 1)
assert.Equal(t, int64(4), pvs[0].DownloadCount)
assert.EqualValues(t, 6, pvs[0].DownloadCount)
})
t.Run("PackageMetadata", func(t *testing.T) {
@@ -217,7 +219,7 @@ func TestPackageNpm(t *testing.T) {
assert.Equal(t, packageBinPath, pmv.Bin[packageBinName])
assert.Equal(t, integrity, pmv.Dist.Integrity)
assert.Equal(t, sha1SumHex, pmv.Dist.Shasum)
assert.Equal(t, fmt.Sprintf("%s%s/-/%s/%s", setting.AppURL, root[1:], packageVersion, filename), pmv.Dist.Tarball)
assert.Equal(t, fmt.Sprintf("%sapi/packages/%s/npm/%s/-/%s", setting.AppURL, user.Name, packageName, filename), pmv.Dist.Tarball)
assert.Equal(t, repoType, result.Repository.Type)
assert.Equal(t, repoURL, result.Repository.URL)
assert.Equal(t, map[string]string{"tea": "2.x", "soy-milk": "1.2"}, pmv.PeerDependencies)
@@ -227,10 +229,24 @@ func TestPackageNpm(t *testing.T) {
assert.Equal(t, map[string]string{"node": ">=22.7.0", "npm": ">=10.8.2"}, pmv.Engines)
assert.Equal(t, []string{"x64", "arm64"}, pmv.CPU)
assert.Equal(t, []string{"linux", "darwin"}, pmv.OS)
assert.Equal(t, []string{"glibc"}, pmv.Libc)
assert.Equal(t, map[string]string{"doc": "./doc", "man": "./man"}, pmv.Directories)
assert.Equal(t, "https://example.com/fund", pmv.Funding)
assert.Equal(t, map[string]string{"left-pad": "1.x"}, pmv.AcceptDependencies)
assert.Empty(t, pmv.Deprecated)
req = NewRequest(t, "GET", root).AddTokenAuth(token).SetHeader("If-None-Match", resp.Header().Get("ETag"))
MakeRequest(t, req, http.StatusNotModified)
})
t.Run("PingWhoami", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
registry := fmt.Sprintf("/api/packages/%s/npm/-/", user.Name)
MakeRequest(t, NewRequest(t, "GET", registry+"ping"), http.StatusOK)
MakeRequest(t, NewRequest(t, "GET", registry+"whoami"), http.StatusUnauthorized)
resp := MakeRequest(t, NewRequest(t, "GET", registry+"whoami").AddTokenAuth(token), http.StatusOK)
assert.JSONEq(t, `{"username":"`+user.Name+`"}`, resp.Body.String())
})
t.Run("PackageVersionMetadata", func(t *testing.T) {
@@ -289,22 +305,6 @@ func TestPackageNpm(t *testing.T) {
assert.Equal(t, packageVersion, result[packageTag2])
})
t.Run("PackageMetadataDistTags", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", root).
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
result := DecodeJSON(t, resp, &npm.PackageMetadata{})
assert.Len(t, result.DistTags, 2)
assert.Contains(t, result.DistTags, packageTag)
assert.Equal(t, packageVersion, result.DistTags[packageTag])
assert.Contains(t, result.DistTags, packageTag2)
assert.Equal(t, packageVersion, result.DistTags[packageTag2])
})
t.Run("DeleteTag", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
@@ -318,6 +318,12 @@ func TestPackageNpm(t *testing.T) {
test(t, http.StatusBadRequest, "1.0")
test(t, http.StatusOK, "dummy")
test(t, http.StatusOK, packageTag2)
test(t, http.StatusOK, packageTag)
resp := MakeRequest(t, NewRequest(t, "GET", tagsRoot).AddTokenAuth(token), http.StatusOK)
assert.Equal(t, map[string]string{packageTag: packageVersion}, DecodeJSON(t, resp, map[string]string{}))
resp = MakeRequest(t, NewRequest(t, "GET", root+"/"+packageTag).AddTokenAuth(token), http.StatusOK)
assert.Equal(t, packageVersion, DecodeJSON(t, resp, &npm.PackageMetadataVersion{}).Version)
})
t.Run("Search", func(t *testing.T) {
@@ -522,7 +528,7 @@ func TestPackageNpm(t *testing.T) {
req := NewRequest(t, "DELETE", fmt.Sprintf("%s/-/%s/%s/-rev/dummy", root, packageVersion, filename))
MakeRequest(t, req, http.StatusUnauthorized)
req = NewRequest(t, "DELETE", fmt.Sprintf("%s/-/%s/%s/-rev/dummy", root, packageVersion, filename)).
req = NewRequest(t, "DELETE", fmt.Sprintf("%s/-/%s/-rev/dummy", root, filename)).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusOK)
@@ -22,6 +22,10 @@ func TestAPIGetRawFileOrLFS(t *testing.T) {
resp := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "# repo1\n\nDescription for repo1", resp.Body.String())
req = NewRequest(t, "GET", "/api/v1/repos/user2/repo2/media/test.xml").AddTokenAuth(getUserToken(t, "user2", auth_model.AccessTokenScopeReadRepository))
resp = MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "text/plain; charset=utf-8", resp.Header().Get("Content-Type"))
// Test with LFS
onGiteaRun(t, func(t *testing.T, u *url.URL) {
createLFSTestRepository(t, "repo-lfs-test")
+10
View File
@@ -16,6 +16,8 @@ import (
user_model "gitea.dev/models/user"
"gitea.dev/modules/git"
"gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
repo_service "gitea.dev/services/repository"
"github.com/stretchr/testify/assert"
@@ -175,6 +177,14 @@ func TestGitPushVisibilityOption(t *testing.T) {
doGitPushTestRepository(gitPath, "origin", "branch2", "-o", "repo.private=false")(t)
repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repo.ID})
assert.True(t, repo.IsPrivate, "repo.private option must be ignored on an existing repository")
defer test.MockVariableValue(&setting.Repository.ForcePrivate, true)()
forcedRepo, err := repo_service.CreateRepository(t.Context(), user, user, repo_service.CreateRepoOptions{Name: "repo-visibility-forced", DefaultBranch: "master", IsPrivate: true})
require.NoError(t, err)
u.Path = forcedRepo.FullName() + ".git"
doGitAddRemote(gitPath, "forced", u)(t)
doGitPushTestRepository(gitPath, "forced", "master", "-o", "repo.private=false")(t)
assert.True(t, unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: forcedRepo.ID}).IsPrivate)
})
}
+9
View File
@@ -571,6 +571,15 @@ func testRefreshTokenInvalidation(t *testing.T) {
assert.Equal(t, "unauthorized_client", string(parsedError.ErrorCode))
assert.Equal(t, "unable to parse refresh token", parsedError.ErrorDescription)
req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
"grant_type": "refresh_token",
"client_id": "da7da3ba-9a13-4167-856f-3899de0b0138",
"client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=",
"redirect_uri": "https://example.com",
"refresh_token": parsed.AccessToken,
})
MakeRequest(t, req, http.StatusBadRequest)
req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
"grant_type": "refresh_token",
"client_id": "da7da3ba-9a13-4167-856f-3899de0b0138",
+6 -6
View File
@@ -378,11 +378,11 @@ func TestCantMergeConflict(t *testing.T) {
BaseBranch: "base",
})
err := pull_service.Merge(pr.ID, user1, repo_model.MergeStyleMerge, "", "CONFLICT", false)
err := pull_service.Merge(t.Context(), pr.ID, user1, repo_model.MergeStyleMerge, "", "CONFLICT", false)
assert.Error(t, err, "Merge should return an error due to conflict")
assert.True(t, pull_service.IsErrMergeConflicts(err), "Merge error is not a conflict error")
err = pull_service.Merge(pr.ID, user1, repo_model.MergeStyleRebase, "", "CONFLICT", false)
err = pull_service.Merge(t.Context(), pr.ID, user1, repo_model.MergeStyleRebase, "", "CONFLICT", false)
assert.Error(t, err, "Merge should return an error due to conflict")
assert.True(t, pull_service.IsErrRebaseConflicts(err), "Merge error is not a conflict error")
})
@@ -473,7 +473,7 @@ func TestCantMergeUnrelated(t *testing.T) {
BaseBranch: "base",
})
err = pull_service.Merge(pr.ID, user1, repo_model.MergeStyleMerge, "", "UNRELATED", false)
err = pull_service.Merge(t.Context(), pr.ID, user1, repo_model.MergeStyleMerge, "", "UNRELATED", false)
assert.Error(t, err, "Merge should return an error due to unrelated")
assert.True(t, pull_service.IsErrMergeUnrelatedHistories(err), "Merge error is not a unrelated histories error")
})
@@ -509,7 +509,7 @@ func TestFastForwardOnlyMerge(t *testing.T) {
BaseBranch: "master",
})
err := pull_service.Merge(pr.ID, user1, repo_model.MergeStyleFastForwardOnly, "", "FAST-FORWARD-ONLY", false)
err := pull_service.Merge(t.Context(), pr.ID, user1, repo_model.MergeStyleFastForwardOnly, "", "FAST-FORWARD-ONLY", false)
assert.NoError(t, err)
})
}
@@ -596,7 +596,7 @@ func TestFastForwardOnlyMergeWithRequiredSignedCommits(t *testing.T) {
pb.RequireSignedCommits = false
require.NoError(t, git_model.UpdateProtectBranch(t.Context(), repo1, pb, git_model.WhitelistOptions{}))
require.NoError(t, pull_service.Merge(pr.ID, user1, repo_model.MergeStyleFastForwardOnly, "", "FAST-FORWARD-ONLY", false))
require.NoError(t, pull_service.Merge(t.Context(), pr.ID, user1, repo_model.MergeStyleFastForwardOnly, "", "FAST-FORWARD-ONLY", false))
})
}
@@ -631,7 +631,7 @@ func TestCantFastForwardOnlyMergeDiverging(t *testing.T) {
BaseBranch: "master",
})
err := pull_service.Merge(pr.ID, user1, repo_model.MergeStyleFastForwardOnly, "", "DIVERGING", false)
err := pull_service.Merge(t.Context(), pr.ID, user1, repo_model.MergeStyleFastForwardOnly, "", "DIVERGING", false)
assert.Error(t, err, "Merge should return an error due to being for a diverging branch")
assert.True(t, pull_service.IsErrMergeDivergingFastForwardOnly(err), "Merge error is not a diverging fast-forward-only error")
})
+9
View File
@@ -84,6 +84,7 @@ func testViewLimitedAndPrivateUserAndRename(t *testing.T) {
org22 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 22})
req := NewRequest(t, "GET", "/"+org22.Name)
MakeRequest(t, req, http.StatusNotFound)
MakeRequest(t, NewRequest(t, "GET", "/"+org22.Name).SetHeader("Accept", "application/rss+xml"), http.StatusNotFound)
session := loginUser(t, "user1")
oldName := org22.Name
@@ -106,6 +107,8 @@ func testViewLimitedAndPrivateUserAndRename(t *testing.T) {
org23 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 23})
req = NewRequest(t, "GET", "/"+org23.Name)
MakeRequest(t, req, http.StatusNotFound)
strangerSession := loginUser(t, "user4")
strangerSession.MakeRequest(t, NewRequest(t, "POST", "/"+org23.Name+"?action=follow"), http.StatusNotFound)
oldName = org23.Name
newName = "org23_renamed"
@@ -127,6 +130,8 @@ func testViewLimitedAndPrivateUserAndRename(t *testing.T) {
user31 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 31})
req = NewRequest(t, "GET", "/"+user31.Name)
MakeRequest(t, req, http.StatusNotFound)
MakeRequest(t, NewRequest(t, "GET", "/"+user31.Name).SetHeader("Accept", "application/rss+xml"), http.StatusNotFound)
strangerSession.MakeRequest(t, NewRequest(t, "POST", "/"+user31.Name+"?action=follow"), http.StatusNotFound)
oldName = user31.Name
newName = "user31_renamed"
@@ -330,6 +335,10 @@ func testGetUserRss(t *testing.T) {
session := loginUser(t, "user2")
req = NewRequestf(t, "GET", "/non-existent-user.rss")
session.MakeRequest(t, req, http.StatusNotFound)
defer test.MockVariableValue(&setting.Other.EnableFeed, false)()
MakeRequest(t, NewRequestf(t, "GET", "/%s.rss", user34), http.StatusNotFound)
MakeRequest(t, NewRequestf(t, "GET", "/%s", user34).SetHeader("Accept", "application/rss+xml"), http.StatusNotFound)
}
func testUserListStopWatches(t *testing.T) {
+7
View File
@@ -2,6 +2,8 @@
/* fonts */
--fonts-proportional: -apple-system, "Segoe UI", system-ui, Roboto, "Helvetica Neue", Arial;
--fonts-monospace: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace, var(--fonts-emoji);
/* Chromium's "math" is a font Linux and ChromeOS don't ship, so fall back to the math fonts they do, https://issues.chromium.org/issues/40069293 */
--fonts-math: math, "STIX Two Math", "DejaVu Math TeX Gyre", "Noto Sans Math";
/* GitHub explicitly sets font names like: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", "Twemoji Mozilla";
Actually "Twemoji Mozilla" emoji font is widely used by browsers like Firefox, Pale Moon, and it is more likely up-to-dated than the system emoji font.
So not setting emoji font seems to be the best choice, here we just use a non-existing dummy font name and let browsers choose. */
@@ -107,6 +109,11 @@ samp,
font-size: 0.95em; /* compensate for monospace fonts being usually slightly larger */
}
math {
font-family: var(--fonts-math);
font-size-adjust: ex-height 0.52; /* match KaTeX's x-height (0.431 × 1.21em) regardless of math font, https://github.com/w3c/mathml-core/issues/41 */
}
/* there are many <code> blocks in non-markup(.markup code) / non-code-diff(code.code-inner) containers, for example: translation strings, etc,
so we need to make <code> have default global styles, ".markup code" has its own styles and these styles sometimes conflict.
TODO: in the future, we should use `div` instead of `code` for `.code-inner` because it is a container for highlighted code line, then drop this ":not" patch */
+13
View File
@@ -1,6 +1,19 @@
import {isDarkTheme} from '../utils.ts';
import {registerGlobalInitFunc} from '../modules/observer.ts';
export async function initCaptcha() {
registerGlobalInitFunc('initImageCaptcha', (el: HTMLElement) => {
const a = el.querySelector('a')!;
a.removeAttribute('href'); // remove generated href="javascript:"
const img = el.querySelector('img')!;
img.removeAttribute('onclick'); // remove generated onclick="...." and use our own event listener
img.addEventListener('click', () => {
const url = new URL(img.src);
url.searchParams.set('reload', String(Date.now()));
img.src = url.href;
});
});
const captchaEl = document.querySelector('#captcha');
if (!captchaEl) return;
-1
View File
@@ -93,7 +93,6 @@ function updateStopwatchData(data: Array<StopwatchData>) {
} else {
const {repo_owner_name, repo_name, issue_index, seconds} = watch;
const issueUrl = `${appSubUrl}/${repo_owner_name}/${repo_name}/issues/${issue_index}`;
for (const btnEl of btnEls) btnEl.setAttribute('href', issueUrl);
document.querySelector('.stopwatch-link')?.setAttribute('href', issueUrl);
document.querySelector('.stopwatch-commit')?.setAttribute('action', `${issueUrl}/times/stopwatch/stop`);
document.querySelector('.stopwatch-cancel')?.setAttribute('action', `${issueUrl}/times/stopwatch/cancel`);
+2 -4
View File
@@ -64,7 +64,7 @@ function replaceWithFeedbackSvg(origSvg: SVGElement, success: boolean): () => vo
// Enable clipboard copy from HTML attributes. These properties are supported:
// - data-clipboard-text: Direct text to copy
// - data-clipboard-target: Holds a selector for an element. "value" of <input> or <textarea>, or "textContent" of <div> will be copied
// - data-clipboard-target: Holds a selector for an element. "value" of <input> or <textarea>, or "textContent" of other elements will be copied
export function initGlobalCopyToClipboardListener() {
document.addEventListener('click', async (e) => {
const target = (e.target as HTMLElement).closest<HTMLElement>('[data-clipboard-text], [data-clipboard-target]');
@@ -78,10 +78,8 @@ export function initGlobalCopyToClipboardListener() {
const textTarget = document.querySelector(textSelector)!;
if (textTarget.nodeName === 'INPUT' || textTarget.nodeName === 'TEXTAREA') {
text = (textTarget as HTMLInputElement | HTMLTextAreaElement).value;
} else if (textTarget.nodeName === 'DIV') {
text = textTarget.textContent;
} else {
throw new Error(`Unsupported element for clipboard target: ${textSelector}`);
text = textTarget.textContent;
}
}
// now, text can not be null