Compare commits

..
38 Commits
Author SHA1 Message Date
58031cdc97 enhance: allow custom watches to bypass on-mention email restriction (#39652) (#39837)
Backport #39652 by @Ubayed-Bin-Sufian

Users with "Only Email on Mention" can now still receive email for the
events they picked in a custom repository watch, covering issues, pull
requests and releases. The setting is renamed to "Only Email on Mention
and Custom Watches" to make this explicit, matching GitHub's
"Participating, @mentions and custom".

"All activity" watches don't bypass the setting, because they are also
created automatically by `AUTO_WATCH_NEW_REPOS` for repository creators
and team members. To keep picking every event distinct from "All
activity", the custom watch dialog now stores a new `WatchModeCustom`.
Existing custom watches keep working without a migration.

Addresses: https://github.com/openSUSE/openSUSE-git/issues/183
Ref: https://github.com/go-gitea/gitea/issues/35492
Co-authored-by: Ubayed Bin Sufian <ubayedbinsufian@gmail.com>
Co-authored-by: bircni <bircni@icloud.com>
2026-10-18 12:45:52 +00:00
1eee73e9bd fix(mailer): reject incoming email tokens of users who cannot sign in (#39833) (#39841)
Backport #39833 by @basavaraj-sm05

Incoming email reply tokens stay valid for a year and are verified with
`user.Rands`, which is not rotated when an account is deactivated or
prohibited from signing in. A suspended user could therefore keep
commenting on public repos by replying to old notifications.

`DecodeToken` now rejects tokens of organizations, inactive users and
users prohibited from signing in, which covers every incoming mail
handler.

Co-authored-by: Basavaraj S m <basavaraj@digiscrypt.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-18 12:28:08 +00:00
Giteabotandwxiaoguang dc30660291 fix: use correct template variable on milestone page (#39834) (#39843)
Backport #39834 by @wxiaoguang

* fix #39828

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-18 11:03:40 +00:00
6bc0e8cd57 fix(pull): recover PRs whose recorded merge commit never reached the base repo (#39831) (#39840)
Backport #39831 by @lunny

When pushing the merge commit to the base repo fails (e.g.
non-fast-forward), the merge intent recorded by
https://github.com/go-gitea/gitea/pull/39442 is left behind.
`merged_commit_id` then points to a commit that does not exist in the
base repo, and `git branch --contains` fails with `no such commit`, so
the PR can never be checked or merged again.

- Clear the recorded merge intent if the push fails and the commit is
not on the base branch
- `IsCommitInBranch` now returns false for a missing commit, so
already-stuck PRs recover on the next check

Fixes: https://github.com/go-gitea/gitea/issues/39824
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-18 03:24:11 -07:00
wxiaoguang cfcbdf41e2 fix: prevent Gitea from crashing by notification panic (#39835) 2026-10-18 11:11:15 +02:00
GiteabotandShivansh Garg 6195cda58f fix(actions): bound request body size in artifacts v4 twirp handlers (#39818) (#39822)
Backport #39818 by @SHIVANSHGARG07

parseProtobufBody read the full Twirp request body via io.ReadAll with
no size limit, allowing an authenticated runner token holder to force
unbounded memory allocation. Cap it at 1 MiB via io.LimitReader,
matching the existing pattern in job_summary.go.

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

Co-authored-by: Shivansh Garg <shivanshgarg587@gmail.com>
2026-10-17 23:17:45 -07:00
silverwind 5251cd572f fix(actions): keep folded block scalars intact across serialization (#39787) (#39817)
Backport https://github.com/go-gitea/gitea/pull/39787 by @lunny,
including the go-yaml bump. The runner counterpart is
https://gitea.com/gitea/runner/pulls/1321.

Folded (`>`) scalars in workflows gained blank lines and had spaces
turned into newlines every time Gitea re-serialized a job.

- go-yaml main fixes the accumulating blank lines
(https://github.com/yaml/go-yaml/pull/366) and the block scalar
indentation indicators (https://github.com/yaml/go-yaml/pull/414), so
`BlockSafeString` goes away
- The job encoder no longer wraps lines, because go-yaml still wraps
more-indented lines of folded scalars and turns a space into a newline

Fixes https://github.com/go-gitea/gitea/issues/39775
2026-10-17 03:15:42 +00:00
Giteabotandsilverwind ce8912c8ff ci: update giteabot to v1.2.1 (#39815) (#39820)
Backport #39815 by @silverwind

Bump `giteabot` from `v1.1.0` to
[`v1.2.1`](https://github.com/go-gitea/giteabot/releases/tag/v1.2.1). It
now runs on the `node24` action runtime instead of setting up Deno
(https://github.com/go-gitea/giteabot/pull/23), and a reviewer who
requested changes can lift `lgtm/blocked` by re-requesting their own
review (https://github.com/go-gitea/giteabot/pull/24).

Co-authored-by: silverwind <me@silverwind.io>
2026-10-17 04:58:14 +02:00
Giteabotandbircni 0e2a188d8e enhance(oauth): pre-register a builtin OAuth2 application for gitea-vscode (#39785) (#39801)
Backport #39785 by @bircni

The [Gitea VS Code extension](https://gitea.com/gitea/gitea-vscode)
currently signs in through the builtin `tea` OAuth2 application. Gitea
keeps one grant per user and application, so anyone who has already
signed in with the tea CLI gets `a grant exists with different scope`
when the extension requests its own scopes, and sign-in fails
(gitea/gitea-vscode#24).

This adds a dedicated builtin application for the extension, following
the same mechanism as the existing CLI entries:

| | |
|---|---|
| client ID | `75a9ec3e-42b5-40cb-9144-9ea96ecd56a6` |
| config name | `gitea-vscode` |
| display name | `gitea-vscode` |
| redirect URIs | `http://127.0.0.1`, `https://127.0.0.1` (loopback, any
port) |

It is enabled by default via `[oauth2].DEFAULT_APPLICATIONS`. Instances
that set that option explicitly need to add `gitea-vscode` to use it.

The extension will use this client on Gitea >= 28.2.0 and keep falling
back to `tea` on older servers, so this should be backported to v28.

Co-authored-by: bircni <bircni@icloud.com>
2026-10-16 12:40:16 -07:00
95d6b572fe fix(pull): don't auto-detect empty pull requests as manually merged (#39807) (#39812)
Backport #39807 by @gomitrah

With "Autodetect manual merge" enabled, an empty pull request (one with
no commits of its own, for example opened to discuss a branch) was
closed as manually merged as soon as it was checked. Its head commit is
already an ancestor of the base branch, so the detection took the
"fast-forwarded" path and recorded the head itself as the merge commit,
even though nothing was merged.

Detection now skips a PR whose head commit is its merge base, the same
condition Gitea already uses to show a PR as "already included in the
target branch". Real manual merges are still detected, including when
the base branch and the PR branch are pushed together.

The merge base is refreshed on recheck, so a fast-forward manual merge
whose merge base was already refreshed to the head is not detected
either. This only misses a detection and never closes a PR wrongly;
telling the two apart would need a value stored at PR creation.

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

Co-authored-by: Mitrahsoft <bala.c@mitrahsoft.in>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-16 21:22:02 +02:00
c4e9d96162 fix(pull): fix code context of review comments on unchanged lines (#39799) (#39805)
Backport #39799

Fixes #39794

Co-authored-by: fixfon <12512757+fixfon@users.noreply.github.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-16 11:53:17 +00:00
96cb6cd16f fix(webauthn): accept unsolicited client extension outputs from WebKit clients (#39792) (#39806)
Backport #39792

Fixes #39790

Signed-off-by: choksi2212 <choksi2212@users.noreply.github.com>
Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Manas Choksi <162600900+choksi2212@users.noreply.github.com>
Co-authored-by: choksi2212 <choksi2212@users.noreply.github.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-16 09:28:24 +00:00
silverwindandwxiaoguang 02b18a0c8e fix: skip commit-graph write when fetching remote commits (#39798)
Since https://github.com/go-gitea/gitea/pull/39607, creating a PR
fetches the head commit into the base repo. Gitea's gitconfig sets
`fetch.writeCommitGraph=true`, so that fetch also writes a commit-graph
and fails with exit 128 when a stale `commit-graph-chain.lock` exists.
This adds `--no-write-commit-graph`, the only part of
https://github.com/go-gitea/gitea/pull/39583 that v28 needs. Its other
flags need git 2.29, while v28 supports git 2.25.

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

This PR was written with Claude.

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-15 14:13:52 +00:00
GiteabotandZettat123 1ffe46d2a4 fix(actions): fix schedule cleanup and default branch switch (#39740) (#39786)
Backport #39740 by @Zettat123

Cancelling running schedule runs has not worked since
https://github.com/go-gitea/gitea/pull/35767: callers pass a branch name
like `main`, while schedule runs store the full ref like
`refs/heads/main`. Disabling Actions, archiving the repository or
changing the default branch left them running.

Fixing the ref alone would also cancel them on every push to the default
branch, which GitHub does not do, and whenever the Actions setting is
saved, so those paths now only replace the schedules.

Changing or renaming the default branch deleted the schedules without
registering the new branch's ones
(https://github.com/go-gitea/gitea/pull/28691), so they stopped until
the next push, and the API path kept the old ones. Both now switch the
schedules to the new default branch, since scheduled workflows only run
on the default branch.

Only the push case was verified on GitHub, as its scheduled workflows
have rarely been triggering lately.

Co-authored-by: Zettat123 <zettat123@gmail.com>
2026-10-14 22:47:59 +00:00
26f537dea6 fix(gitdiff): unquote C-quoted paths from git diff-tree --raw (#39781) (#39783)
Backport #39781 by @SudhanshuMatrix

## What

`parseGitDiffTreeLine` (`services/gitdiff/git_diff_tree.go`) read
`BasePath`/`HeadPath` straight from `git diff-tree --raw` output without
undoing git's C-quoting, unlike its sibling `parseLsTreeLine`
(`modules/git/parse.go`), which already does this with
`strconv.Unquote`.

This fix applies the same unquoting to both paths in the diff-tree
parser.

## Why

With the default `core.quotePath=true`, git C-quotes **any path
containing a non-ASCII byte** — not just tabs/newlines/backslashes. That
means any file with a CJK, Cyrillic, accented-Latin, or emoji name gets
octal-escaped, e.g.:

```
:100644 100644 b6fc4c6... 04fea06... M	"\346\227\245\346\234\254\350\252\236.txt"
```
(`日本語.txt`, confirmed against real `git diff-tree --raw` output)

`GetDiffTree` backs the file-tree sidebar on the commit, compare, and
pull request diff pages (`routers/web/repo/commit.go`, `compare.go`,
`pull.go`). Without unquoting, any such file shows its mangled, escaped
name instead of the real one, and the wrong path can throw off
jump-to-file and viewed-file-state behavior that key off the path.

Co-authored-by: Shudhanshu Singh <sudhanshuwriterblc@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-14 20:35:32 +00:00
GiteabotandYunus Ozen 74e7e813c3 fix(user): don't match a commit with an empty author email to a user (#39777) (#39780)
Backport #39777

Co-authored-by: Yunus Ozen <94426948+ozenyunus35@users.noreply.github.com>
2026-10-14 21:00:10 +02:00
Giteabotandsilverwind 3f0aa7a01e fix(markup): restore code copy newline strip and fix copying rendered blocks (#39755) (#39765)
Backport #39755 by @silverwind

1. Fix JS error when copying Mermaid and Math block sources, regression
from https://github.com/go-gitea/gitea/pull/39718.
1. Restore the newline trim which is consistent with the majority of
forges and markdown renderers as per
https://github.com/go-gitea/gitea/pull/39751#issuecomment-6129002625 and
my strong preference.

This is the minimal fixes to fix the bug and restore the existing
behaviour, more discussions and followups can be had in
https://github.com/go-gitea/gitea/pull/39751.

Co-authored-by: silverwind <me@silverwind.io>
2026-10-13 23:43:12 -07:00
7e1e32b42e fix(lfs): validate OID before building filesystem object path (#39762) (#39766)
Backport #39762

Co-authored-by: Basavaraj S m <basavaraj@digiscrypt.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-13 17:19:59 -07:00
63669003eb fix(markup): resolve and proxy srcset links of <source> elements (#39708) (#39756)
Backport #39708

Co-authored-by: Basavaraj S m <basavaraj@digiscrypt.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-13 15:46:51 +08:00
Giteabotandwxiaoguang f5fd936cbb fix: correct label deletion and release attachment load (#39743) (#39752)
Backport #39743 by wxiaoguang

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-13 00:53:02 +00:00
wxiaoguang d87bbd3dd4 fix: validate "state" for OpenID (#39642) (#39744) 2026-10-13 08:26:37 +08:00
7a03ec3a4c fix(issues): allow selecting a tag in the issue Branch/Tag dropdown (#39747) (#39753)
Backport #39747 by @AliceLafox

* Fixes #39746
* Fixes #35505

The click handlers were bound only to the first `.reference-list-menu`
(branches), so clicking a tag did nothing. Bind them to the items of
both lists.

Co-authored-by: Alisa Lafoks <alice@lafox.net>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-12 18:29:43 +00:00
Giteabotandsilverwind 0132cb82c2 fix(packages/npm): restore former tarball URLs (#39681) (#39749)
Backport #39681 by @silverwind

Gitea changed the `dist.tarball` it advertises for already published
versions twice:

| Gitea | `dist.tarball` path of `@scope/name@1.0.0` |
|---|---|
| 1.27 | `%40scope%2Fname/-/1.0.0/name-1.0.0.tgz` |
| 28.0, https://github.com/go-gitea/gitea/pull/39061 |
`@scope%2Fname/-/1.0.0/name-1.0.0.tgz` |
| 28.1, https://github.com/go-gitea/gitea/pull/39434 |
`@scope/name/-/name-1.0.0.tgz` |

pnpm 11.5.2+ verifies the tarball URLs pinned in its lockfile against
the registry metadata, so frozen installs of lockfiles written against
1.27 fail with `ERR_PNPM_TARBALL_URL_MISMATCH`.

- Versions uploaded before this PR get their 1.27 URL back.
- New versions store their canonical tarball path at upload, so later
URL changes can't reach published versions. The host still follows the
request.

Lockfiles written against 28.0 for scoped packages, or by pnpm 12 with
`@scope:registry` against 28.1, need those pinned `tarball:` entries
removed, as 1.27 lockfiles of the same versions need the other URL.

Signed-off-by: silverwind <me@silverwind.io>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-12 20:10:28 +02:00
wxiaoguang 33aaf0cfa3 fix(auth): prevent competing remember-me token rotations (#39686) (#39688)
Partially backport #39686
2026-10-12 09:48:37 +00:00
6064554b8d fix(packages): copy selected Debian installation values (#39718) (#39734)
Backport #39718

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

Make "copy code" button work with changed content.

Co-authored-by: Ehsan-hash <150884269+Ehsan-hash@users.noreply.github.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-11 11:33:37 +02:00
d29364a4f6 fix(actions): harden log cursor, activity type and actor filter handling (#39721) (#39731)
Backport #39721 by @bircni

Fixes a few Actions bugs where user input or nondeterminism caused
failures:

- The job log view indexed task steps with the client-supplied cursor
step without a bounds check, so an out-of-range value panicked and
returned a 500.
- Workflow activity type filters (`types:`) were compiled with
`glob.MustCompile`, so an invalid pattern in a workflow file panicked
during event detection. Invalid patterns now simply don't match.
- Listing workflow runs with an unknown `actor` returned a 500; it now
returns 404.
- The v4 artifact `ListArtifacts` response was built by ranging over a
map, so its order changed between calls. It now follows database order.
- The error from marshalling a workflow job when inserting run jobs was
silently ignored.

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-10 20:23:39 +00:00
Giteabotandsilverwind 998a06627e fix(git): match git config values exactly when syncing (#39728) (#39729)
Backport #39728 by @silverwind

Since https://github.com/go-gitea/gitea/pull/39703, Gitea fails to start
when its gitconfig holds a `gc.reflogExpire` other than `90` (e.g.
`30.days`), because `git config --unset-all` exits 5 when no value
matches. Value patterns were also unanchored regexps, so `90` also
removed `90.days`.

- Match values exactly in `configUnsetAll` and `configAddNonExist`
- Treat "nothing to unset" as success
- Use `--replace-all` in `configSet` so a key with multiple values no
longer fails startup

Co-authored-by: silverwind <me@silverwind.io>
2026-10-10 20:06:48 +02:00
250d18d5f6 fix(actions): settle cancelled reusable workflow runs stuck in cancelling (#39706) (#39717)
Backport #39706 by @benv666

Fixes #39702

**Root Cause:**

Cancelling a reusable workflow caller wrote it `cancelled` right away,
even while a child was still `cancelling`. When the runner acknowledged,
the caller did not change, so the run was never refreshed and stayed
`cancelling`.

SQLite hid this because the child is cancelled a second time, which
re-aggregates the caller, while MySQL reports no affected rows for that
unchanged update.

**Changes:**

- A caller now takes its status only from its children: it stays
`cancelling` until its last child finishes, and its final status then
refreshes the run.
- Cancelling a run whose jobs are all done only settles the run, which
did not wake the runs waiting for its concurrency group. It now does, so
force-cancelling an already stuck run also frees them.


NOTE: Code was AI-generated, but reviewed and tested by me.

Signed-off-by: BenV <165034+benv666@users.noreply.github.com>
Co-authored-by: BenV <165034+benv666@users.noreply.github.com>
Co-authored-by: Zettat123 <zettat123@gmail.com>
2026-10-10 09:13:47 -06:00
Giteabotandsilverwind c26daf578f fix(git): leave reflog expiry to git's defaults (#39703) (#39711)
Backport #39703 by @silverwind

Gitea's default `gc.reflogExpire = 90` is parsed by git as
`1990-<month>-<day>`, currently `1990-10-09`, so reachable reflog
entries never expired.

Since git 2.54, auto maintenance runs its reflog-expire task in the
foreground of every push, and its trigger ignores reachability, so busy
repos rerun `git reflog expire --all` on every push without pruning
anything, stalling large repos for over a minute.

- Stop setting `gc.reflogExpire` so git's own defaults apply, and remove
the `90` written by earlier versions
- Treat the legacy `[git.reflog] EXPIRATION` as days, as documented

The next push to each repo prunes the accumulated entries once.

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

Co-authored-by: silverwind <me@silverwind.io>
2026-10-10 00:36:00 -07:00
silverwind 5371e788fe fix(deps): update module golang.org/x/net to v0.60.0 [security] (#39695) (#39701)
Backport https://github.com/go-gitea/gitea/pull/39695

`go.sum` conflicted on older x/net `go.mod` hashes that `release/v28`
still lists. `make tidy` resolves it to the same change as on `main`.
2026-10-08 23:31:32 -07:00
GiteabotandShivansh Garg 0001e58e6b fix: OIDC discovery advertises unsupported id_token response_type (#39498) (#39689)
Backport #39498 by @SHIVANSHGARG07

The OIDC discovery document at `/.well-known/openid-configuration`
advertised `id_token` in `response_types_supported`, but
`/login/oauth/authorize` only implements the authorization code flow and
rejects any other response_type with `unsupported_response_type`. This
mismatch caused OIDC client libraries that rely on discovery to attempt
the implicit flow and fail silently.

This removes `id_token` from `response_types_supported` so discovery
matches actual server behavior, and adds an integration test asserting
`response_type=id_token` is rejected consistently.

Manually verified: rebuilt Gitea, registered an OAuth2 app, confirmed
`/.well-known/openid-configuration` no longer lists `id_token`, and
confirmed `/login/oauth/authorize?...&response_type=id_token` still
correctly returns `error=unsupported_response_type`.

Fixes #39482.

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

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

Co-authored-by: Shivansh Garg <shivanshgarg587@gmail.com>
2026-10-08 18:57:06 +00:00
Giteabot 79c4237cb2 chore(deps): update go toolchain directive to v1.27.2 (#39685) (#39687)
Backport #39685 by @GiteaBot

This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [go](https://go.dev/)
([source](https://redirect.github.com/golang/go)) | toolchain | patch |
`1.27.1` → `1.27.2` |

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR has been generated by [Mend Renovate
CLI](https://redirect.github.com/renovatebot/renovate).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4xMjEuNCIsInVwZGF0ZWRJblZlciI6IjQ0LjEyMS40IiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJkZXBlbmRlbmNpZXMiXX0=-->
2026-10-08 11:25:28 -07:00
Giteabotandbircni 20f73b2230 ci: skip reverted commits in release notes, publish tagged snaps to stable (#39657) (#39678)
Backport #39657 by @bircni

Release notes: revert commits and the commits they revert are now left
out of the git-cliff changelog. A step before git-cliff finds commits
whose subject starts with "revert", reads the PR numbers on their revert
lines, and writes both SHAs to `.cliffignore`.

Snap: since the move from Launchpad to GitHub Actions, every build was
uploaded to `latest/edge`, including the stable-grade build that
`part-gitea-pull.sh` makes for an unreleased tag. Launchpad used to
release those builds to candidate and stable automatically. Without
that, v28.0.0 had to be promoted by hand and v28.1.0 stayed in edge.
Stable-grade snaps now go to `latest/stable` and `latest/candidate`;
main builds still go to edge. Candidate has to be updated too because
the pull script compares against it.

Signed-off-by: bircni <bircni@icloud.com>
Co-authored-by: bircni <bircni@icloud.com>
2026-10-08 15:57:37 +02:00
175bc89bf9 fix: correct "go get" URL ssh scheme, fix form binding (#39674, #39528) (#39676)
* Backport #39674
* Fix form binding errors (found by #39528)
* Fix #39680

---------

Co-authored-by: Roland Singer <10167163+r0l1@users.noreply.github.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-08 12:30:59 +00:00
GiteabotandKShot 793e77b073 fix(webhook): keep line breaks in Telegram rich messages (#39650) (#39677)
Backport #39650 by @Kshot3000

Since the Telegram webhook switched to Bot API rich messages
(https://github.com/go-gitea/gitea/pull/38298), a bare newline in
`rich_message.html` is treated as insignificant whitespace, so
multi-line messages — issue and PR bodies, comments, push commit lists —
arrive in Telegram as a single paragraph. This restores the old layout
by converting line breaks (`\n`, `\r\n`, `\r`) to `<br>` in
`createTelegramPayloadHTML`, after sanitizing, covering every Telegram
payload type at once.

Verified: the new `Line breaks are kept in rich messages` test plus the
updated Push/Issue/IssueComment/PullRequest/Review expectations fail on
unpatched code (literal `\n` in the payload) and pass with the fix; the
full `services/webhook` package passes, gofmt/vet clean.

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

---
Tips welcome: PayPal kyleblake0659@gmail.com · BTC
3GnR7TWBXAB3pPztBWpNF4LMNEX5yX8vZK

Co-authored-by: KShot <kshot9000@gmail.com>
2026-10-08 13:31:11 +02:00
wxiaoguang 5018ae1b29 fix: correct websocket notification for change password page (#39671) (#39670)
* Backport #39671
* Fix #39669

And also fix a UI bug
2026-10-07 19:26:34 +00:00
c60b13b2dd fix(markup): display MathML has no space below it (#39663) (#39668)
Backport #39663 by @nschloe

Since MathML is allowed in markup (#36352, #38034, #39337), a
display-style `<math display="block">` sits flush against the text below
it: it gets no margin of its own, and markup paragraphs have
`margin-top: 0`. Display math rendered by KaTeX gets 16px above and
below.

This adds `math[display="block"]` to the markup block elements that get
`margin-top: 0; margin-bottom: 16px`, like `p`, `pre` and `table`.

To test, view a Markdown file containing

```markdown
Text before.

<math display="block">
<mi>x</mi><mo>=</mo><mn>1</mn>
</math>

Text after.
```

Before, the equation has 16px above and 0px below; after, 16px on both
sides.

Before:

<img width="1544" height="238" alt="before"
src="https://github.com/user-attachments/assets/318238d9-018c-4945-974c-91bd1b282ff2"
/>

After:

<img width="1544" height="283" alt="after"
src="https://github.com/user-attachments/assets/9afde2be-b5ed-4624-a33f-4933e7fae79b"
/>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Nico Schlömer <nschloe@users.noreply.github.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-07 18:23:27 +00:00
Giteabotandwxiaoguang 27bd022b34 fix: various bugs (#39661) (#39667)
Backport #39661

1. fix #39660: relax email validation
2. fix #39658: use "int64" instead of time.Duration (for JSON v2)

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-07 10:11:58 -07:00
141 changed files with 1748 additions and 465 deletions

No files matched your search

+1 -1
View File
@@ -56,7 +56,7 @@ jobs:
pull-requests: write pull-requests: write
statuses: write statuses: write
steps: steps:
- uses: go-gitea/giteabot@f48c6a15e0d384f037aea19cc05ff5e9551096b9 # v1.1.0 - uses: go-gitea/giteabot@8033358e118c0bd97f5e9902dc5d6633eadc6aeb # v1.2.1
with: with:
github_token: ${{ secrets.GITEABOT_TOKEN }} github_token: ${{ secrets.GITEABOT_TOKEN }}
checks: ${{ github.event.inputs.checks || 'labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions' }} checks: ${{ github.event.inputs.checks || 'labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions' }}
@@ -37,8 +37,15 @@ jobs:
# retry snapcraft uploads which can be flaky # retry snapcraft uploads which can be flaky
- name: Publish snap - name: Publish snap
run: | run: |
# part-gitea-pull.sh decides what is built, see the comments there:
# * grade devel (main or a prerelease tag) goes to latest/edge
# * grade stable (a new release tag) goes to latest/stable, and to latest/candidate which marks it as released
channel=latest/edge
if unsquashfs -cat "$SNAP" meta/snap.yaml | grep -qx 'grade: stable'; then
channel=latest/stable,latest/candidate
fi
for attempt in 1 2 3 4 5; do for attempt in 1 2 3 4 5; do
snapcraft upload "$SNAP" --release latest/edge && exit 0 snapcraft upload "$SNAP" --release "$channel" && exit 0
echo "::warning::snap upload attempt $attempt failed, retrying in 15s" echo "::warning::snap upload attempt $attempt failed, retrying in 15s"
sleep 15 sleep 15
done done
@@ -76,6 +76,10 @@ jobs:
run: | run: |
previous=$(git tag --list --sort=-v:refname | grep -xE 'v[0-9]+\.[0-9]+\.[0-9]+' | grep -A1 -xF "$GITHUB_REF_NAME" | tail -1) # highest stable version below this one previous=$(git tag --list --sort=-v:refname | grep -xE 'v[0-9]+\.[0-9]+\.[0-9]+' | grep -A1 -xF "$GITHUB_REF_NAME" | tail -1) # highest stable version below this one
echo "range=$previous..$GITHUB_SHA" >> "$GITHUB_OUTPUT" echo "range=$previous..$GITHUB_SHA" >> "$GITHUB_OUTPUT"
- name: skip reverts and reverted commits in changelog
env:
RANGE: ${{ steps.range.outputs.range }}
run: ./tools/generate-cliffignore.sh "$RANGE" | sort -u > .cliffignore
- uses: orhun/git-cliff-action@a9a95522b26fe6403f7bb24031f21fb573d0f5ff # v4.9.1 - uses: orhun/git-cliff-action@a9a95522b26fe6403f7bb24031f21fb573d0f5ff # v4.9.1
with: with:
args: --tag ${{ github.ref_name }} ${{ steps.range.outputs.range }} args: --tag ${{ github.ref_name }} ${{ steps.range.outputs.range }}
+1 -1
View File
@@ -6,7 +6,7 @@ SHASUM ?= shasum -a 256
AIR_PACKAGE ?= github.com/air-verse/air@v1.67.4 # renovate: datasource=go AIR_PACKAGE ?= github.com/air-verse/air@v1.67.4 # renovate: datasource=go
EDITORCONFIG_CHECKER_PACKAGE ?= github.com/editorconfig-checker/editorconfig-checker/v4/cmd/editorconfig-checker@v4.0.2 # renovate: datasource=go EDITORCONFIG_CHECKER_PACKAGE ?= github.com/editorconfig-checker/editorconfig-checker/v4/cmd/editorconfig-checker@v4.0.2 # renovate: datasource=go
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.2 # renovate: datasource=go GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.14.0 # renovate: datasource=go
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.17 # renovate: datasource=go GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.17 # renovate: datasource=go
MISSPELL_PACKAGE ?= github.com/golangci/misspell/cmd/misspell@v0.8.0 # renovate: datasource=go MISSPELL_PACKAGE ?= github.com/golangci/misspell/cmd/misspell@v0.8.0 # renovate: datasource=go
SWAGGER_PACKAGE ?= github.com/go-swagger/go-swagger/cmd/swagger@v0.36.6 # renovate: datasource=go SWAGGER_PACKAGE ?= github.com/go-swagger/go-swagger/cmd/swagger@v0.36.6 # renovate: datasource=go
+5 -4
View File
@@ -638,7 +638,8 @@ ENABLED = true
;; * https://github.com/git-ecosystem/git-credential-manager ;; * https://github.com/git-ecosystem/git-credential-manager
;; * https://gitea.com/gitea/tea ;; * https://gitea.com/gitea/tea
;; * Gitea App (the official Gitea mobile app) ;; * Gitea App (the official Gitea mobile app)
;DEFAULT_APPLICATIONS = git-credential-oauth, git-credential-manager, tea, gitea-app ;; * https://gitea.com/gitea/gitea-vscode
;DEFAULT_APPLICATIONS = git-credential-oauth, git-credential-manager, tea, gitea-app, gitea-vscode
;; ;;
;; By default, OAuth2 applications can only use "http" and "https" as their redirect URI schemes. ;; By default, OAuth2 applications can only use "http" and "https" as their redirect URI schemes.
;; If you need to use other schemes (e.g. for desktop applications), you can specify them here as a comma-separated list. ;; If you need to use other schemes (e.g. for desktop applications), you can specify them here as a comma-separated list.
@@ -810,7 +811,6 @@ LEVEL = Info
;[git.config] ;[git.config]
;diff.algorithm = histogram ;diff.algorithm = histogram
;core.logAllRefUpdates = true ;core.logAllRefUpdates = true
;gc.reflogExpire = 90
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -1098,8 +1098,9 @@ LEVEL = Info
;; Force ssh:// clone url instead of scp-style uri when default SSH port is used ;; Force ssh:// clone url instead of scp-style uri when default SSH port is used
;USE_COMPAT_SSH_URI = false ;USE_COMPAT_SSH_URI = false
;; ;;
;; Value for the "go get" request returns the repository url as https or ssh, default is https ;; Scheme of the returned URL for the "go get" response.
;GO_GET_CLONE_URL_PROTOCOL = https ;; Default is "https" if DISABLE_HTTP_GIT=false or SSH is disabled, otherwise "ssh".
;GO_GET_CLONE_URL_PROTOCOL =
;; ;;
;; Close issues as long as a commit on any branch marks it as fixed ;; Close issues as long as a commit on any branch marks it as fixed
;DEFAULT_CLOSE_ISSUES_VIA_COMMITS_IN_ANY_BRANCH = false ;DEFAULT_CLOSE_ISSUES_VIA_COMMITS_IN_ANY_BRANCH = false
+3 -3
View File
@@ -2,7 +2,7 @@ module gitea.dev
go 1.27 go 1.27
toolchain go1.27.1 toolchain go1.27.2
require ( require (
connectrpc.com/connect v1.21.0 connectrpc.com/connect v1.21.0
@@ -94,11 +94,11 @@ require (
github.com/yuin/goldmark v1.8.6 github.com/yuin/goldmark v1.8.6
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
gitlab.com/gitlab-org/api/client-go/v3 v3.13.0 gitlab.com/gitlab-org/api/client-go/v3 v3.13.0
go.yaml.in/yaml/v4 v4.0.0-rc.6 go.yaml.in/yaml/v4 v4.0.0-rc.6.0.20261013184558-04dd63131525
golang.org/x/crypto v0.57.0 golang.org/x/crypto v0.57.0
golang.org/x/image v0.46.0 golang.org/x/image v0.46.0
golang.org/x/mod v0.41.0 golang.org/x/mod v0.41.0
golang.org/x/net v0.59.0 golang.org/x/net v0.60.0
golang.org/x/oauth2 v0.37.0 golang.org/x/oauth2 v0.37.0
golang.org/x/sync v0.23.0 golang.org/x/sync v0.23.0
golang.org/x/sys v0.48.0 golang.org/x/sys v0.48.0
+4 -4
View File
@@ -672,8 +672,8 @@ go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4= go.yaml.in/yaml/v4 v4.0.0-rc.6.0.20261013184558-04dd63131525 h1:FtzHH3s777Nx50YcPAcodAPm1mNakc228nMYbr3f+90=
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= go.yaml.in/yaml/v4 v4.0.0-rc.6.0.20261013184558-04dd63131525/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw= go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw=
go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0= go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -714,8 +714,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+20 -11
View File
@@ -853,25 +853,34 @@ func cancelReusableCaller(ctx context.Context, caller *ActionRunJob, force bool)
} }
// Cancel descendants deepest-first, then the caller: a caller's status is aggregated from its children, // Cancel descendants deepest-first, then the caller: a caller's status is aggregated from its children,
// so each child must reach its final state before its parent caller is re-aggregated. // so each child must be cancelled before its parent caller is re-read.
// A child's ID always exceeds its parent's, so descending ID is a valid deepest-first order. // A child's ID always exceeds its parent's, so descending ID is a valid deepest-first order.
descendants := CollectAllDescendantJobs(caller, attemptJobs) descendants := CollectAllDescendantJobs(caller, attemptJobs)
slices.SortFunc(descendants, func(a, b *ActionRunJob) int { return cmp.Compare(b.ID, a.ID) }) slices.SortFunc(descendants, func(a, b *ActionRunJob) int { return cmp.Compare(b.ID, a.ID) })
callersWithChildren := make(container.Set[int64])
for _, d := range descendants {
callersWithChildren.Add(d.ParentJobID)
}
for _, c := range descendants { for _, job := range append(descendants, caller) {
cancelled, err := cancelOneJob(ctx, c, force) if !callersWithChildren.Contains(job.ID) {
cancelled, err := cancelOneJob(ctx, job, force)
if err != nil {
return cancelledJobs, err
}
if cancelled != nil {
cancelledJobs = append(cancelledJobs, cancelled)
}
continue
}
// the job is a caller and its children's cascade already re-aggregated it
reloaded, err := GetRunJobByRunAndID(ctx, job.RunID, job.ID)
if err != nil { if err != nil {
return cancelledJobs, err return cancelledJobs, err
} }
if cancelled != nil { if reloaded.Status != job.Status {
cancelledJobs = append(cancelledJobs, cancelled) cancelledJobs = append(cancelledJobs, reloaded)
} }
} }
if c, err := cancelOneJob(ctx, caller, force); err != nil {
return cancelledJobs, err
} else if c != nil {
cancelledJobs = append(cancelledJobs, c)
}
return cancelledJobs, nil return cancelledJobs, nil
} }
+70
View File
@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"testing" "testing"
runnerv1 "gitea.dev/actionslib/runner/v1"
"gitea.dev/models/db" "gitea.dev/models/db"
"gitea.dev/models/unittest" "gitea.dev/models/unittest"
"gitea.dev/modules/timeutil" "gitea.dev/modules/timeutil"
@@ -397,3 +398,72 @@ func TestForceCancelJobs(t *testing.T) {
assert.Equal(t, StatusCancelled, callerAfter.Status) assert.Equal(t, StatusCancelled, callerAfter.Status)
}) })
} }
func TestCancelJobs_CallerWaitsForCancellingChild(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
run := &ActionRun{
Title: "caller-cancelling-child",
RepoID: 4,
Index: 9811,
OwnerID: 1,
WorkflowID: "test.yaml",
TriggerUserID: 1,
Ref: "refs/heads/master",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
Event: "push",
TriggerEvent: "push",
EventPayload: "{}",
Status: StatusRunning,
}
require.NoError(t, db.Insert(ctx, run))
attempt := &ActionRunAttempt{RepoID: run.RepoID, RunID: run.ID, Attempt: 1, TriggerUserID: 1, Status: StatusRunning}
require.NoError(t, db.Insert(ctx, attempt))
run.LatestAttemptID = attempt.ID
require.NoError(t, UpdateRun(ctx, run, "latest_attempt_id"))
newJob := func(name string, parentID int64, isCaller bool) *ActionRunJob {
job := &ActionRunJob{
RunID: run.ID,
RunAttemptID: attempt.ID,
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
Name: name,
JobID: name,
Attempt: 1,
Status: StatusRunning,
ParentJobID: parentID,
IsReusableCaller: isCaller,
IsExpanded: isCaller,
}
require.NoError(t, db.Insert(ctx, job))
return job
}
outer := newJob("outer", 0, true)
inner := newJob("inner", outer.ID, true)
child := newJob("child", inner.ID, false)
runner := &ActionRunner{UUID: "caller-cancelling-child", Name: "caller-cancelling-child", HasCancellingSupport: true}
require.NoError(t, db.Insert(ctx, runner))
task := &ActionTask{JobID: child.ID, Attempt: 1, RunnerID: runner.ID, Status: StatusRunning, Started: timeutil.TimeStampNow(), RepoID: run.RepoID, OwnerID: run.OwnerID, CommitSHA: run.CommitSHA}
require.NoError(t, db.Insert(ctx, task))
child.TaskID = task.ID
_, err := UpdateRunJob(ctx, child, nil, "task_id")
require.NoError(t, err)
cancelled, err := CancelJobs(ctx, []*ActionRunJob{outer}, false)
require.NoError(t, err)
assert.Len(t, cancelled, 3)
for _, job := range []*ActionRunJob{outer, inner, child} {
assert.Equal(t, StatusCancelling, unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID}).Status, job.Name)
}
_, err = UpdateTaskByState(ctx, runner.ID, &runnerv1.TaskState{Id: task.ID, Result: runnerv1.Result_RESULT_CANCELLED})
require.NoError(t, err)
for _, job := range []*ActionRunJob{outer, inner, child} {
assert.Equal(t, StatusCancelled, unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID}).Status, job.Name)
}
assert.Equal(t, StatusCancelled, unittest.AssertExistsAndLoadBean(t, &ActionRun{ID: run.ID}).Status)
}
+2 -1
View File
@@ -11,6 +11,7 @@ import (
"gitea.dev/models/db" "gitea.dev/models/db"
repo_model "gitea.dev/models/repo" repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
"gitea.dev/modules/git"
"gitea.dev/modules/timeutil" "gitea.dev/modules/timeutil"
"gitea.dev/modules/util" "gitea.dev/modules/util"
webhook_module "gitea.dev/modules/webhook" webhook_module "gitea.dev/modules/webhook"
@@ -110,7 +111,7 @@ func CleanRepoScheduleTasks(ctx context.Context, repo *repo_model.Repository) ([
jobs, err := CancelPreviousJobs( jobs, err := CancelPreviousJobs(
ctx, ctx,
repo.ID, repo.ID,
repo.DefaultBranch, git.RefNameFromBranch(repo.DefaultBranch).String(),
"", "",
webhook_module.HookEventSchedule, webhook_module.HookEventSchedule,
) )
+2 -2
View File
@@ -236,13 +236,13 @@ func GetRunningTaskByToken(ctx context.Context, token string) (*ActionTask, erro
func makeTaskStepDisplayName(step *jobparser.Step, limit int) (name string) { func makeTaskStepDisplayName(step *jobparser.Step, limit int) (name string) {
if step.Name != "" { if step.Name != "" {
name = string(step.Name) // the step has an explicit name name = step.Name // the step has an explicit name
} else { } else {
// for unnamed step, its "String()" method tries to get a display name by its "name", "uses", // for unnamed step, its "String()" method tries to get a display name by its "name", "uses",
// "run" or "id" (last fallback), we add the "Run " prefix for unnamed steps for better display // "run" or "id" (last fallback), we add the "Run " prefix for unnamed steps for better display
// for multi-line "run" scripts, only use the first line to match GitHub's behavior // for multi-line "run" scripts, only use the first line to match GitHub's behavior
// https://github.com/actions/runner/blob/66800900843747f37591b077091dd2c8cf2c1796/src/Runner.Worker/Handlers/ScriptHandler.cs#L45-L58 // https://github.com/actions/runner/blob/66800900843747f37591b077091dd2c8cf2c1796/src/Runner.Worker/Handlers/ScriptHandler.cs#L45-L58
runStr, _, _ := strings.Cut(strings.TrimSpace(string(step.Run)), "\n") runStr, _, _ := strings.Cut(strings.TrimSpace(step.Run), "\n")
name = "Run " + util.IfZero(strings.TrimSpace(runStr), step.String()) name = "Run " + util.IfZero(strings.TrimSpace(runStr), step.String())
} }
return util.EllipsisDisplayString(name, limit) // database column has a length limit return util.EllipsisDisplayString(name, limit) // database column has a length limit
+2 -2
View File
@@ -94,14 +94,14 @@ func TestMakeTaskStepDisplayName(t *testing.T) {
{ {
name: "very long name truncated", name: "very long name truncated",
jobStep: &jobparser.Step{ jobStep: &jobparser.Step{
Name: jobparser.BlockSafeString(strings.Repeat("a", 300)), Name: strings.Repeat("a", 300),
}, },
expected: strings.Repeat("a", 252) + "…", expected: strings.Repeat("a", 252) + "…",
}, },
{ {
name: "very long run truncated", name: "very long run truncated",
jobStep: &jobparser.Step{ jobStep: &jobparser.Step{
Run: jobparser.BlockSafeString(strings.Repeat("a", 300)), Run: strings.Repeat("a", 300),
}, },
expected: "Run " + strings.Repeat("a", 248) + "…", expected: "Run " + strings.Repeat("a", 248) + "…",
}, },
+4 -3
View File
@@ -44,9 +44,10 @@ func GetAuthTokenByID(ctx context.Context, id string) (*AuthToken, error) {
return at, nil return at, nil
} }
func UpdateAuthTokenByID(ctx context.Context, t *AuthToken) error { func UpdateAuthTokenByID(ctx context.Context, t *AuthToken, oldHash string) (bool, error) {
_, err := db.GetEngine(ctx).ID(t.ID).Cols("token_hash", "expires_unix").Update(t) affected, err := db.GetEngine(ctx).ID(t.ID).Where("token_hash = ?", oldHash).
return err Cols("token_hash", "expires_unix").Update(t)
return affected == 1, err
} }
func DeleteAuthTokenByID(ctx context.Context, id string) error { func DeleteAuthTokenByID(ctx context.Context, id string) error {
+10
View File
@@ -88,6 +88,16 @@ func BuiltinApplications() map[string]*BuiltinOAuth2Application {
DisplayName: "Gitea App", DisplayName: "Gitea App",
RedirectURIs: []string{"com.gitea.app://oauth/callback"}, RedirectURIs: []string{"com.gitea.app://oauth/callback"},
} }
m["75a9ec3e-42b5-40cb-9144-9ea96ecd56a6"] = &BuiltinOAuth2Application{
ConfigName: "gitea-vscode",
DisplayName: "gitea-vscode",
RedirectURIs: []string{
"http://127.0.0.1",
"https://127.0.0.1",
"vscode://gitea.gitea-for-vscode/auth/callback",
"vscode-insiders://gitea.gitea-for-vscode/auth/callback",
},
}
return m return m
} }
+5 -3
View File
@@ -99,8 +99,8 @@ func (protectBranch *ProtectedBranch) loadGlob() {
var err error var err error
protectBranch.globRule, err = glob.Compile(protectBranch.RuleName, '/') protectBranch.globRule, err = glob.Compile(protectBranch.RuleName, '/')
if err != nil { if err != nil {
log.Warn("Invalid glob rule for ProtectedBranch[%d]: %s %v", protectBranch.ID, protectBranch.RuleName, err) log.Debug("Invalid glob rule for ProtectedBranch[%d]: %s %v", protectBranch.ID, protectBranch.RuleName, err)
protectBranch.globRule = glob.MustCompile(glob.QuoteMeta(protectBranch.RuleName), '/') protectBranch.globRule, _ = glob.Compile(glob.QuoteMeta(protectBranch.RuleName), '/')
} }
} }
@@ -110,7 +110,9 @@ func (protectBranch *ProtectedBranch) Match(branchName string) bool {
if protectBranch.isPlainName { if protectBranch.isPlainName {
return strings.EqualFold(protectBranch.RuleName, branchName) return strings.EqualFold(protectBranch.RuleName, branchName)
} }
if protectBranch.globRule == nil {
return false // in case of invalid glob rule, we don't match anything
}
return protectBranch.globRule.Match(branchName) return protectBranch.globRule.Match(branchName)
} }
+12 -6
View File
@@ -9,6 +9,7 @@ import (
"gitea.dev/models/db" "gitea.dev/models/db"
"gitea.dev/modules/glob" "gitea.dev/modules/glob"
"gitea.dev/modules/log"
"gitea.dev/modules/optional" "gitea.dev/modules/optional"
) )
@@ -55,10 +56,17 @@ func FindRepoProtectedBranchRules(ctx context.Context, repoID int64) (ProtectedB
// FindAllMatchedBranches find all matched branches // FindAllMatchedBranches find all matched branches
func FindAllMatchedBranches(ctx context.Context, repoID int64, ruleName string) ([]string, error) { func FindAllMatchedBranches(ctx context.Context, repoID int64, ruleName string) ([]string, error) {
results := make([]string, 0, 10) results := make([]string, 0, 10)
rule, err := glob.Compile(ruleName)
if err != nil {
log.Debug("Failed to compile rule %s: %v", ruleName, err)
return results, nil
}
const pageSize = 100
for page := 1; ; page++ { for page := 1; ; page++ {
brancheNames, err := FindBranchNames(ctx, FindBranchOptions{ branchNames, err := FindBranchNames(ctx, FindBranchOptions{
ListOptions: db.ListOptions{ ListOptions: db.ListOptions{
PageSize: 100, PageSize: pageSize,
Page: page, Page: page,
}, },
RepoID: repoID, RepoID: repoID,
@@ -67,14 +75,12 @@ func FindAllMatchedBranches(ctx context.Context, repoID int64, ruleName string)
if err != nil { if err != nil {
return nil, err return nil, err
} }
rule := glob.MustCompile(ruleName) for _, branch := range branchNames {
for _, branch := range brancheNames {
if rule.Match(branch) { if rule.Match(branch) {
results = append(results, branch) results = append(results, branch)
} }
} }
if len(brancheNames) < 100 { if len(branchNames) < pageSize {
break break
} }
} }
+1 -1
View File
@@ -81,7 +81,7 @@ type Issue struct {
PullRequest *PullRequest `xorm:"-"` PullRequest *PullRequest `xorm:"-"`
NumComments int NumComments int
// TODO: RemoveIssueRef: see "repo/issue/branch_selector_field.tmpl" // TODO: ISSUE-SIDEBAR-REF-SELECTOR: see "repo/issue/branch_selector_field.tmpl"
Ref string Ref string
PinOrder int `xorm:"-"` // 0 means not loaded, -1 means loaded but not pinned PinOrder int `xorm:"-"` // 0 means not loaded, -1 means loaded but not pinned
+1 -1
View File
@@ -477,7 +477,7 @@ func applySubscribedCondition(sess db.Session, subscriberID int64) {
Select("repo_id"). Select("repo_id").
From("watch"). From("watch").
Where(builder.And(builder.Eq{"user_id": subscriberID}, Where(builder.And(builder.Eq{"user_id": subscriberID},
builder.In("mode", repo_model.WatchModeNormal, repo_model.WatchModeAuto))), builder.In("mode", repo_model.WatchModeNormal, repo_model.WatchModeAuto, repo_model.WatchModeCustom))),
), ),
), ),
) )
+14 -18
View File
@@ -269,27 +269,23 @@ func UpdateLabel(ctx context.Context, l *Label) error {
return updateLabelCols(ctx, l, "name", "description", "color", "exclusive", "exclusive_order", "archived_unix") return updateLabelCols(ctx, l, "name", "description", "color", "exclusive", "exclusive_order", "archived_unix")
} }
// DeleteLabel delete a label // DeleteRepoLabel deletes a repository label.
func DeleteLabel(ctx context.Context, id, labelID int64) error { func DeleteRepoLabel(ctx context.Context, repoID, labelID int64) error {
l, err := GetLabelByID(ctx, labelID) return deleteLabel(ctx, labelID, builder.Eq{"repo_id": repoID, "org_id": 0})
if err != nil { }
if IsErrLabelNotExist(err) {
return nil
}
return err
}
// DeleteOrgLabel deletes an organization label.
func DeleteOrgLabel(ctx context.Context, orgID, labelID int64) error {
return deleteLabel(ctx, labelID, builder.Eq{"org_id": orgID, "repo_id": 0})
}
func deleteLabel(ctx context.Context, labelID int64, ownerCond builder.Cond) error {
return db.WithTx(ctx, func(ctx context.Context) error { return db.WithTx(ctx, func(ctx context.Context) error {
if l.BelongsToOrg() && l.OrgID != id { deleted, err := db.GetEngine(ctx).ID(labelID).Where(ownerCond).Delete(new(Label))
return nil if err != nil || deleted == 0 {
}
if l.BelongsToRepo() && l.RepoID != id {
return nil
}
if _, err = db.DeleteByID[Label](ctx, labelID); err != nil {
return err return err
} else if _, err = db.GetEngine(ctx). }
if _, err = db.GetEngine(ctx).
Where("label_id = ?", labelID). Where("label_id = ?", labelID).
Delete(new(IssueLabel)); err != nil { Delete(new(IssueLabel)); err != nil {
return err return err
+19 -3
View File
@@ -320,13 +320,29 @@ func TestUpdateLabel(t *testing.T) {
func TestDeleteLabel(t *testing.T) { func TestDeleteLabel(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())
label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 1}) label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 1})
assert.NoError(t, issues_model.DeleteLabel(t.Context(), label.RepoID, label.ID)) orgLabel := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 3})
repoLabel := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 10, RepoID: orgLabel.OrgID})
assert.NoError(t, issues_model.DeleteOrgLabel(t.Context(), repoLabel.RepoID, repoLabel.ID))
unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: repoLabel.ID})
assert.NoError(t, issues_model.DeleteRepoLabel(t.Context(), orgLabel.OrgID, orgLabel.ID))
unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: orgLabel.ID})
assert.NoError(t, issues_model.DeleteOrgLabel(t.Context(), unittest.NonexistentID, orgLabel.ID))
unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: orgLabel.ID})
assert.NoError(t, issues_model.DeleteOrgLabel(t.Context(), orgLabel.OrgID, orgLabel.ID))
unittest.AssertNotExistsBean(t, &issues_model.Label{ID: orgLabel.ID})
assert.NoError(t, issues_model.DeleteRepoLabel(t.Context(), unittest.NonexistentID, label.ID))
unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: label.ID})
assert.NoError(t, issues_model.DeleteRepoLabel(t.Context(), label.RepoID, label.ID))
unittest.AssertNotExistsBean(t, &issues_model.Label{ID: label.ID, RepoID: label.RepoID}) unittest.AssertNotExistsBean(t, &issues_model.Label{ID: label.ID, RepoID: label.RepoID})
assert.NoError(t, issues_model.DeleteLabel(t.Context(), label.RepoID, label.ID)) assert.NoError(t, issues_model.DeleteRepoLabel(t.Context(), label.RepoID, label.ID))
unittest.AssertNotExistsBean(t, &issues_model.Label{ID: label.ID}) unittest.AssertNotExistsBean(t, &issues_model.Label{ID: label.ID})
assert.NoError(t, issues_model.DeleteLabel(t.Context(), unittest.NonexistentID, unittest.NonexistentID)) assert.NoError(t, issues_model.DeleteRepoLabel(t.Context(), unittest.NonexistentID, unittest.NonexistentID))
assert.NoError(t, issues_model.DeleteOrgLabel(t.Context(), unittest.NonexistentID, unittest.NonexistentID))
unittest.CheckConsistencyFor(t, &issues_model.Label{}, &repo_model.Repository{}) unittest.CheckConsistencyFor(t, &issues_model.Label{}, &repo_model.Repository{})
} }
+18
View File
@@ -9,6 +9,7 @@ import (
repo_model "gitea.dev/models/repo" repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest" "gitea.dev/models/unittest"
"gitea.dev/modules/markup/markdown" "gitea.dev/modules/markup/markdown"
"gitea.dev/modules/test"
_ "gitea.dev/modules/markup/orgmode" _ "gitea.dev/modules/markup/orgmode"
@@ -80,6 +81,23 @@ func TestRepoFile(t *testing.T) {
<video src="/user2/repo1/media/commit/1234/my%20dir/LINK"> <video src="/user2/repo1/media/commit/1234/my%20dir/LINK">
</video>`, rendered) </video>`, rendered)
}) })
t.Run("PictureSource", func(t *testing.T) {
rctx := NewRenderContextRepoFile(t.Context(), repo1, RepoFileOptions{CurrentRefSubURL: "branch/main"}).
WithMarkupType(markdown.MarkupName)
rendered, err := testRenderString(rctx, `<picture><source media="(prefers-color-scheme: dark)" srcset="dark.svg"><img src="light.svg"></picture>`)
assert.NoError(t, err)
assert.Equal(t, test.NormalizeHTMLSpaces(`
<p>
<a href="/user2/repo1/src/branch/main/light.svg" target="_blank" rel="nofollow noopener">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="/user2/repo1/media/branch/main/dark.svg"/>
<img src="/user2/repo1/media/branch/main/light.svg"/>
</picture>
</a>
</p>
`), test.NormalizeHTMLSpaces(rendered))
})
} }
func TestRepoFileOrgMode(t *testing.T) { func TestRepoFileOrgMode(t *testing.T) {
+22 -15
View File
@@ -357,21 +357,21 @@ func GetLatestReleaseByRepoID(ctx context.Context, repoID int64) (*Release, erro
} }
type releaseMetaSearch struct { type releaseMetaSearch struct {
ID []int64 IDs []int64
Rel []*Release Releases []*Release
} }
func (s releaseMetaSearch) Len() int { func (s releaseMetaSearch) Len() int {
return len(s.ID) return len(s.IDs)
} }
func (s releaseMetaSearch) Swap(i, j int) { func (s releaseMetaSearch) Swap(i, j int) {
s.ID[i], s.ID[j] = s.ID[j], s.ID[i] s.IDs[i], s.IDs[j] = s.IDs[j], s.IDs[i]
s.Rel[i], s.Rel[j] = s.Rel[j], s.Rel[i] s.Releases[i], s.Releases[j] = s.Releases[j], s.Releases[i]
} }
func (s releaseMetaSearch) Less(i, j int) bool { func (s releaseMetaSearch) Less(i, j int) bool {
return s.ID[i] < s.ID[j] return s.IDs[i] < s.IDs[j]
} }
func hasDuplicateName(attaches []*Attachment) bool { func hasDuplicateName(attaches []*Attachment) bool {
@@ -396,19 +396,26 @@ func GetReleaseAttachments(ctx context.Context, rels ...*Release) (err error) {
// then merge join them // then merge join them
// Sort // Sort
sortedRels := releaseMetaSearch{ID: make([]int64, len(rels)), Rel: make([]*Release, len(rels))} sortedRels := releaseMetaSearch{IDs: make([]int64, 0, len(rels)), Releases: make([]*Release, 0, len(rels))}
var attachments []*Attachment var attachments []*Attachment
for index, element := range rels { for _, rel := range rels {
element.Attachments = []*Attachment{} // if a provided "Release" has no ID, don't query it, otherwise, the "IN" query will return all other attachments for the repo
sortedRels.ID[index] = element.ID if rel.ID == 0 {
sortedRels.Rel[index] = element continue
}
rel.Attachments = []*Attachment{}
sortedRels.IDs = append(sortedRels.IDs, rel.ID)
sortedRels.Releases = append(sortedRels.Releases, rel)
}
if len(sortedRels.IDs) == 0 {
return nil
} }
sort.Sort(sortedRels) sort.Sort(sortedRels)
// Select attachments // Select attachments
err = db.GetEngine(ctx). err = db.GetEngine(ctx).
Asc("release_id", "name"). Asc("release_id", "name").
In("release_id", sortedRels.ID). In("release_id", sortedRels.IDs).
Find(&attachments) Find(&attachments)
if err != nil { if err != nil {
return err return err
@@ -417,14 +424,14 @@ func GetReleaseAttachments(ctx context.Context, rels ...*Release) (err error) {
// merge join // merge join
currentIndex := 0 currentIndex := 0
for _, attachment := range attachments { for _, attachment := range attachments {
for sortedRels.ID[currentIndex] < attachment.ReleaseID { for sortedRels.IDs[currentIndex] < attachment.ReleaseID {
currentIndex++ currentIndex++
} }
sortedRels.Rel[currentIndex].Attachments = append(sortedRels.Rel[currentIndex].Attachments, attachment) sortedRels.Releases[currentIndex].Attachments = append(sortedRels.Releases[currentIndex].Attachments, attachment)
} }
// Makes URL's predictable // Makes URL's predictable
for _, release := range rels { for _, release := range sortedRels.Releases {
// If we have no Repo, we don't need to execute this loop // If we have no Repo, we don't need to execute this loop
if release.Repo == nil { if release.Repo == nil {
continue continue
+10 -1
View File
@@ -616,6 +616,15 @@ func ComposeHTTPSCloneURL(ctx context.Context, owner, repo string) string {
// ComposeSSHCloneURL returns SSH clone URL based on the given owner and repository name. // ComposeSSHCloneURL returns SSH clone URL based on the given owner and repository name.
func ComposeSSHCloneURL(doer *user_model.User, ownerName, repoName string) string { func ComposeSSHCloneURL(doer *user_model.User, ownerName, repoName string) string {
return composeSSHCloneURL(doer, ownerName, repoName, setting.Repository.UseCompatSSHURI)
}
// ComposeSSHCloneURI is like ComposeSSHCloneURL but always returns the "ssh://" form, because "go get" rejects scp-style addresses
func ComposeSSHCloneURI(doer *user_model.User, ownerName, repoName string) string {
return composeSSHCloneURL(doer, ownerName, repoName, true)
}
func composeSSHCloneURL(doer *user_model.User, ownerName, repoName string, useURI bool) string {
sshUser := setting.SSH.User sshUser := setting.SSH.User
sshDomain := setting.SSH.Domain sshDomain := setting.SSH.Domain
@@ -642,7 +651,7 @@ func ComposeSSHCloneURL(doer *user_model.User, ownerName, repoName string) strin
if ip := net.ParseIP(sshHost); ip != nil && ip.To4() == nil { if ip := net.ParseIP(sshHost); ip != nil && ip.To4() == nil {
sshHost = "[" + sshHost + "]" // for IPv6 address, wrap it with brackets sshHost = "[" + sshHost + "]" // for IPv6 address, wrap it with brackets
} }
if setting.Repository.UseCompatSSHURI { if useURI {
return fmt.Sprintf("ssh://%s@%s/%s/%s.git", sshUser, sshHost, url.PathEscape(ownerName), url.PathEscape(repoName)) return fmt.Sprintf("ssh://%s@%s/%s/%s.git", sshUser, sshHost, url.PathEscape(ownerName), url.PathEscape(repoName))
} }
return fmt.Sprintf("%s@%s:%s/%s.git", sshUser, sshHost, url.PathEscape(ownerName), url.PathEscape(repoName)) return fmt.Sprintf("%s@%s:%s/%s.git", sshUser, sshHost, url.PathEscape(ownerName), url.PathEscape(repoName))
+2
View File
@@ -185,6 +185,8 @@ func TestComposeSSHCloneURL(t *testing.T) {
assert.Equal(t, "git@domain:user/repo.git", ComposeSSHCloneURL(&user_model.User{Name: "doer"}, "user", "repo")) assert.Equal(t, "git@domain:user/repo.git", ComposeSSHCloneURL(&user_model.User{Name: "doer"}, "user", "repo"))
setting.Repository.UseCompatSSHURI = true setting.Repository.UseCompatSSHURI = true
assert.Equal(t, "ssh://git@domain/user/repo.git", ComposeSSHCloneURL(&user_model.User{Name: "doer"}, "user", "repo")) assert.Equal(t, "ssh://git@domain/user/repo.git", ComposeSSHCloneURL(&user_model.User{Name: "doer"}, "user", "repo"))
setting.Repository.UseCompatSSHURI = false
assert.Equal(t, "ssh://git@domain/user/repo.git", ComposeSSHCloneURI(nil, "user", "repo"))
// test SSH_DOMAIN while use non-standard SSH port // test SSH_DOMAIN while use non-standard SSH port
setting.SSH.Port = 123 setting.SSH.Port = 123
setting.Repository.UseCompatSSHURI = false setting.Repository.UseCompatSSHURI = false
+28 -7
View File
@@ -20,9 +20,10 @@ type WatchMode int8
const ( const (
WatchModeNone WatchMode = iota // 0 watch nothing unless mentioned WatchModeNone WatchMode = iota // 0 watch nothing unless mentioned
WatchModeNormal // 1 proactively watching (all or custom) WatchModeNormal // 1 proactively watching (all, or custom before WatchModeCustom)
WatchModeDont // 2 ignore the repo WatchModeDont // 2 ignore the repo
WatchModeAuto // 3 automatically watching (from AutoWatchOnChanges) WatchModeAuto // 3 automatically watching (from AutoWatchOnChanges)
WatchModeCustom // 4 watching the events picked in the custom watch options
) )
// WatchType is the `watch` column gating one kind of notification // WatchType is the `watch` column gating one kind of notification
@@ -79,7 +80,7 @@ func (w *Watch) IsWatching() bool {
// IsWatchingAll reports whether every event is enabled, which is the "all activity" mode // IsWatchingAll reports whether every event is enabled, which is the "all activity" mode
func (w *Watch) IsWatchingAll() bool { func (w *Watch) IsWatchingAll() bool {
return w.IncludePullRequests && w.IncludeIssues && w.IncludeReleases return w.Mode != WatchModeCustom && w.IncludePullRequests && w.IncludeIssues && w.IncludeReleases
} }
func (w *Watch) IsWatchingAny() bool { func (w *Watch) IsWatchingAny() bool {
@@ -166,6 +167,9 @@ func WatchRepoAuto(ctx context.Context, doer *user_model.User, repo *Repository,
if user_model.IsUserBlockedBy(ctx, doer, repo.OwnerID) { if user_model.IsUserBlockedBy(ctx, doer, repo.OwnerID) {
return user_model.ErrBlockedUser return user_model.ErrBlockedUser
} }
if watch.Mode == WatchModeCustom { // keep the events the user picked
return nil
}
return watchRepoByMode(ctx, watch, WatchModeNormal) return watchRepoByMode(ctx, watch, WatchModeNormal)
} }
@@ -189,7 +193,7 @@ func WatchRepoWithOptions(ctx context.Context, doer *user_model.User, repo *Repo
if err != nil { if err != nil {
return err return err
} }
if opts.Mode == WatchModeNormal { if opts.Mode == WatchModeNormal || opts.Mode == WatchModeCustom {
_, err = db.GetEngine(ctx).Where("user_id=? AND repo_id=?", doer.ID, repo.ID). _, err = db.GetEngine(ctx).Where("user_id=? AND repo_id=?", doer.ID, repo.ID).
Cols("include_pull_requests", "include_issues", "include_releases"). Cols("include_pull_requests", "include_issues", "include_releases").
Update(&Watch{IncludePullRequests: opts.WatchPullRequests, IncludeIssues: opts.WatchIssues, IncludeReleases: opts.WatchReleases}) Update(&Watch{IncludePullRequests: opts.WatchPullRequests, IncludeIssues: opts.WatchIssues, IncludeReleases: opts.WatchReleases})
@@ -247,6 +251,16 @@ func GetRepoIgnorersIDs(ctx context.Context, repoID int64) ([]int64, error) {
// but avoids joining with `user` for performance reasons // but avoids joining with `user` for performance reasons
// User permissions must be verified elsewhere if required // User permissions must be verified elsewhere if required
func GetRepoWatchersIDs(ctx context.Context, repoID int64, watchType WatchType) ([]int64, error) { func GetRepoWatchersIDs(ctx context.Context, repoID int64, watchType WatchType) ([]int64, error) {
return getRepoWatchersIDs(ctx, repoID, watchType, false)
}
// GetRepoCustomWatchersIDs is GetRepoWatchersIDs limited to custom watches, which unlike
// watching all activity are never set up automatically (e.g. by AutoWatchNewRepos)
func GetRepoCustomWatchersIDs(ctx context.Context, repoID int64, watchType WatchType) ([]int64, error) {
return getRepoWatchersIDs(ctx, repoID, watchType, true)
}
func getRepoWatchersIDs(ctx context.Context, repoID int64, watchType WatchType, customOnly bool) ([]int64, error) {
ids := make([]int64, 0, 64) ids := make([]int64, 0, 64)
var watchColName string var watchColName string
switch watchType { switch watchType {
@@ -259,12 +273,19 @@ func GetRepoWatchersIDs(ctx context.Context, repoID int64, watchType WatchType)
default: default:
panic("invalid WatchType") panic("invalid WatchType")
} }
return ids, db.GetEngine(ctx).Table("watch"). sess := db.GetEngine(ctx).Table("watch").
Where("watch.repo_id=?", repoID). Where("watch.repo_id=?", repoID).
And("watch.mode<>?", WatchModeDont). And("watch.mode<>?", WatchModeDont).
And(builder.Eq{watchColName: true}). And(builder.Eq{watchColName: true})
Select("user_id"). if customOnly { // matches SelectedMode() == "custom"
Find(&ids) sess = sess.And(builder.Or(
builder.Eq{"watch.mode": WatchModeCustom},
builder.Eq{"watch.include_pull_requests": false},
builder.Eq{"watch.include_issues": false},
builder.Eq{"watch.include_releases": false},
))
}
return ids, sess.Select("user_id").Find(&ids)
} }
// GetRepoWatchers returns range of users watching given repository. // GetRepoWatchers returns range of users watching given repository.
+16 -1
View File
@@ -145,7 +145,7 @@ func TestWatchOptions(t *testing.T) {
// repo 1 is watched by users 1, 4, 9 and 11, all with every event enabled // repo 1 is watched by users 1, 4, 9 and 11, all with every event enabled
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
assert.NoError(t, repo_model.WatchRepoWithOptions(t.Context(), user, repo, repo_model.WatchOptions{Mode: repo_model.WatchModeNormal, WatchPullRequests: true})) assert.NoError(t, repo_model.WatchRepoWithOptions(t.Context(), user, repo, repo_model.WatchOptions{Mode: repo_model.WatchModeNormal, WatchPullRequests: true})) // custom watches were stored like this before WatchModeCustom
for watchType, expected := range map[repo_model.WatchType][]int64{ for watchType, expected := range map[repo_model.WatchType][]int64{
repo_model.WatchPullRequests: {1, 4, 9, 11}, repo_model.WatchPullRequests: {1, 4, 9, 11},
@@ -157,6 +157,20 @@ func TestWatchOptions(t *testing.T) {
assert.ElementsMatch(t, expected, ids, watchType) assert.ElementsMatch(t, expected, ids, watchType)
} }
// a custom watch picking every event differs from all activity, which can be set up automatically
customUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
assert.NoError(t, repo_model.WatchRepoWithOptions(t.Context(), customUser, repo, repo_model.WatchOptions{Mode: repo_model.WatchModeCustom, WatchPullRequests: true, WatchIssues: true, WatchReleases: true}))
assert.NoError(t, repo_model.WatchRepoAuto(t.Context(), customUser, repo, true)) // must keep the custom watch
for watchType, expected := range map[repo_model.WatchType][]int64{
repo_model.WatchPullRequests: {1, 4},
repo_model.WatchIssues: {4},
repo_model.WatchReleases: {4},
} {
ids, err := repo_model.GetRepoCustomWatchersIDs(t.Context(), repo.ID, watchType)
assert.NoError(t, err)
assert.ElementsMatch(t, expected, ids, watchType)
}
// the options of one user must not show up for another // the options of one user must not show up for another
watches, err := repo_model.GetUserWatches(t.Context(), 4, []int64{repo.ID}) watches, err := repo_model.GetUserWatches(t.Context(), 4, []int64{repo.ID})
assert.NoError(t, err) assert.NoError(t, err)
@@ -176,5 +190,6 @@ func TestWatchSelectedMode(t *testing.T) {
assert.Equal(t, "participate", (&repo_model.Watch{Mode: repo_model.WatchModeNormal}).SelectedMode()) assert.Equal(t, "participate", (&repo_model.Watch{Mode: repo_model.WatchModeNormal}).SelectedMode())
assert.Equal(t, "ignore", (&repo_model.Watch{Mode: repo_model.WatchModeDont}).SelectedMode()) assert.Equal(t, "ignore", (&repo_model.Watch{Mode: repo_model.WatchModeDont}).SelectedMode())
assert.Equal(t, "custom", (&repo_model.Watch{Mode: repo_model.WatchModeNormal, IncludeIssues: true}).SelectedMode()) assert.Equal(t, "custom", (&repo_model.Watch{Mode: repo_model.WatchModeNormal, IncludeIssues: true}).SelectedMode())
assert.Equal(t, "custom", (&repo_model.Watch{Mode: repo_model.WatchModeCustom, IncludePullRequests: true, IncludeIssues: true, IncludeReleases: true}).SelectedMode())
assert.Equal(t, "all", (&repo_model.Watch{Mode: repo_model.WatchModeAuto, IncludePullRequests: true, IncludeIssues: true, IncludeReleases: true}).SelectedMode()) assert.Equal(t, "all", (&repo_model.Watch{Mode: repo_model.WatchModeAuto, IncludePullRequests: true, IncludeIssues: true, IncludeReleases: true}).SelectedMode())
} }
+20 -8
View File
@@ -151,16 +151,28 @@ func TestListEmails(t *testing.T) {
func TestEmailAddressValidate(t *testing.T) { func TestEmailAddressValidate(t *testing.T) {
cases := map[string]bool{ cases := map[string]bool{
"": false, "": false,
"root@localhost": true, "@a": false,
"user@[192.168.1.2]": true,
"@a": false, // "_" shouldn't appear in domain but can appear in hostname, since we can't stop site admins from doing so, just accept it
"abc@gmail.com": true, "root@local_host": true,
"abc@gmail.com\n": false, "root@localhost": true,
"root@LOCALHOST": true,
"user@[192.168.1.2]": true,
"user@[IPv6:FFff::1]": true,
"abc@gmail.com": true,
"abc@gmail.com.": false,
"abc@gmail.com-": false,
"abc@gmail.com\n": false,
"abc@gmail..com": false,
"abc@gmail com": false,
"abc@gmail*com": false,
"Foo <foo@bar.com>": false, "Foo <foo@bar.com>": false,
"abc@gmail.com (x)": false, "abc@gmail.com (x)": false,
"jürgen@example.com": false, "jürgen@example.com": false, // utf8 address is not supported yet
"a@foo_bar.com": false,
} }
for tc, isValid := range cases { for tc, isValid := range cases {
t.Run(tc, func(t *testing.T) { t.Run(tc, func(t *testing.T) {
+31 -2
View File
@@ -1021,7 +1021,8 @@ func GetInactiveUsers(ctx context.Context, olderThan time.Duration) ([]*User, er
Find(&users) Find(&users)
} }
// GetUserByID returns the user object by given ID if exists. // GetUserByID returns the user object by given ID if exists. TODO: for most cases, this function should be avoided.
// Use correct GetXxxUser functions instead to handle user types & states correctly.
func GetUserByID(ctx context.Context, id int64) (*User, error) { func GetUserByID(ctx context.Context, id int64) (*User, error) {
u := new(User) u := new(User)
has, err := db.GetEngine(ctx).ID(id).Get(u) has, err := db.GetEngine(ctx).ID(id).Get(u)
@@ -1033,7 +1034,8 @@ func GetUserByID(ctx context.Context, id int64) (*User, error) {
return u, nil return u, nil
} }
// GetUserByIDs returns the user objects by given IDs if exists. // GetUserByIDs returns the user objects by given IDs if exists. TODO: for most cases, this function should be avoided.
// Use correct GetXxxUser functions instead to handle user types & states correctly.
func GetUserByIDs(ctx context.Context, ids []int64) ([]*User, error) { func GetUserByIDs(ctx context.Context, ids []int64) ([]*User, error) {
if len(ids) == 0 { if len(ids) == 0 {
return nil, nil return nil, nil
@@ -1046,6 +1048,13 @@ func GetUserByIDs(ctx context.Context, ids []int64) ([]*User, error) {
return users, err return users, err
} }
// Glossaries for XxxUser:
// * Possible: The user may or may not exist in the database, but we can still return a valid user object (e.g. Ghost user) for the given ID.
// * Individual: The user is a real person, not an organization or a bot.
// * Doer: The user is the one performing an action
// * Valid: The user is an active user and not prohibited from signing in
// * System: Negative ID (e.g. Ghost, Actions, DeployKey, etc.) and not a real person, Ghost is not included if there is no "Possible" keyword
// GetPossibleUserByID returns the possible user and its ID. If the user doesn't exist, it returns Ghost user // GetPossibleUserByID returns the possible user and its ID. If the user doesn't exist, it returns Ghost user
func GetPossibleUserByID(ctx context.Context, id int64) (_ int64, u *User, err error) { func GetPossibleUserByID(ctx context.Context, id int64) (_ int64, u *User, err error) {
if id < 0 { if id < 0 {
@@ -1082,6 +1091,23 @@ func GetPossibleUserByIDs(ctx context.Context, ids []int64) ([]*User, error) {
return users, nil return users, nil
} }
func GetValidDoerUserByID(ctx context.Context, id int64) (*User, error) {
u, err := GetUserByID(ctx, id)
if err != nil {
return nil, err
}
if !u.IsIndividual() && !u.IsTypeBot() {
return nil, util.NewNotExistErrorf("user %d is not a doer", id)
}
if !u.IsActive {
return nil, util.NewPermissionDeniedErrorf("user %d is inactive", id)
}
if u.ProhibitLogin {
return nil, util.NewPermissionDeniedErrorf("user %d is prohibited from login", id)
}
return u, nil
}
func getUserByNameWithTypes(ctx context.Context, name string, types ...UserType) (*User, error) { func getUserByNameWithTypes(ctx context.Context, name string, types ...UserType) (*User, error) {
u := &User{} u := &User{}
sess := db.GetEngine(ctx).Where(builder.Eq{"lower_name": strings.ToLower(name)}) sess := db.GetEngine(ctx).Where(builder.Eq{"lower_name": strings.ToLower(name)})
@@ -1201,6 +1227,9 @@ func GetUsersByEmails(ctx context.Context, emails []string) (*EmailUserMap, erro
noReplyAddressSuffix := "@" + strings.ToLower(setting.Service.NoReplyAddress) noReplyAddressSuffix := "@" + strings.ToLower(setting.Service.NoReplyAddress)
for _, email := range emails { for _, email := range emails {
emailLower := strings.ToLower(email) emailLower := strings.ToLower(email)
if emailLower == "" {
continue // a commit without an author email names no user
}
needCheckEmails.Add(emailLower) needCheckEmails.Add(emailLower)
if localPart, ok := strings.CutSuffix(emailLower, noReplyAddressSuffix); ok { if localPart, ok := strings.CutSuffix(emailLower, noReplyAddressSuffix); ok {
name, id := parseLocalPartToNameID(localPart) name, id := parseLocalPartToNameID(localPart)
+6
View File
@@ -95,6 +95,12 @@ func TestUserEmails(t *testing.T) {
setting.Service.NoReplyAddress = "example.com" setting.Service.NoReplyAddress = "example.com"
testGetUserByEmail(t, "user1-2@example.COM", 1) testGetUserByEmail(t, "user1-2@example.COM", 1)
}) })
t.Run("EmptyEmail", func(t *testing.T) {
// A user whose email address is empty must not own commits that have no author email.
require.NoError(t, db.Insert(t.Context(), &user_model.EmailAddress{UID: 2, Email: "", LowerEmail: "", IsActivated: true}))
testGetUserByEmail(t, "", 0)
})
}) })
t.Run("GetUserByEmail", func(t *testing.T) { t.Run("GetUserByEmail", func(t *testing.T) {
testGetUserByEmail := func(t *testing.T, email string, uid int64) { testGetUserByEmail := func(t *testing.T, email string, uid int64) {
+23 -48
View File
@@ -4,7 +4,6 @@
package jobparser package jobparser
import ( import (
"bytes"
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
@@ -65,44 +64,31 @@ func (w *SingleWorkflow) SetJob(id string, job *Job) error {
m := map[string]*Job{ m := map[string]*Job{
id: job, id: job,
} }
var buf bytes.Buffer out, err := marshalYAML(m)
encoder := yaml.NewEncoder(&buf) if err != nil {
encoder.SetIndent(2)
if err := encoder.Encode(m); err != nil {
return err return err
} }
encoder.Close()
node := yaml.Node{} node := yaml.Node{}
if err := yaml.Unmarshal(buf.Bytes(), &node); err != nil { if err := yaml.Unmarshal(out, &node); err != nil {
return err return err
} }
if len(node.Content) != 1 || node.Content[0].Kind != yaml.MappingNode { if len(node.Content) != 1 || node.Content[0].Kind != yaml.MappingNode {
return fmt.Errorf("can not set job: %s", buf.String()) return fmt.Errorf("can not set job: %s", out)
} }
w.RawJobs = *node.Content[0] w.RawJobs = *node.Content[0]
return nil return nil
} }
func (w *SingleWorkflow) Marshal() ([]byte, error) { func (w *SingleWorkflow) Marshal() ([]byte, error) {
// Encode with the same indentation SetJob uses (2). yaml.Marshal's default
// indentation (4) makes the encoder emit multi-line block scalars (e.g. a
// `run:` step that begins with blank lines) with a wrong explicit indentation
// indicator (`run: |4`) that then fails to re-parse, which silently strands
// the job during concurrency evaluation. Keeping both encoders at indent 2
// makes the serialized single workflow round-trip.
var buf bytes.Buffer
enc := yaml.NewEncoder(&buf)
enc.SetIndent(2)
payload := *w payload := *w
payload.RunName = "" // already interpolated into the run title, a runner would parse it as a template again payload.RunName = "" // already interpolated into the run title, a runner would parse it as a template again
if err := enc.Encode(&payload); err != nil { return marshalYAML(&payload)
return nil, err }
}
if err := enc.Close(); err != nil { // marshalYAML never wraps lines, go-yaml wraps more-indented lines of folded scalars until https://github.com/yaml/go-yaml/pull/441 is released.
return nil, err func marshalYAML(v any) ([]byte, error) {
} return yaml.Dump(v, yaml.WithV3Defaults(), yaml.WithIndent(2), yaml.WithLineWidth(-1))
return buf.Bytes(), nil
} }
type Job struct { type Job struct {
@@ -188,29 +174,18 @@ func (j *Job) DisplayName() string {
return util.EllipsisDisplayString(unescapeExpressions(j.Name), 255) return util.EllipsisDisplayString(unescapeExpressions(j.Name), 255)
} }
// BlockSafeString works around https://github.com/yaml/go-yaml/issues/399, quoting a value whose
// leading newline would cost a literal block scalar its indentation indicator.
type BlockSafeString string
func (s BlockSafeString) MarshalYAML() (any, error) {
if !strings.HasPrefix(string(s), "\n") {
return string(s), nil
}
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Style: yaml.DoubleQuotedStyle, Value: string(s)}, nil
}
type Step struct { type Step struct {
ID string `yaml:"id,omitempty"` ID string `yaml:"id,omitempty"`
If yaml.Node `yaml:"if,omitempty"` If yaml.Node `yaml:"if,omitempty"`
Name BlockSafeString `yaml:"name,omitempty"` Name string `yaml:"name,omitempty"`
Uses string `yaml:"uses,omitempty"` Uses string `yaml:"uses,omitempty"`
Run BlockSafeString `yaml:"run,omitempty"` Run string `yaml:"run,omitempty"`
WorkingDirectory string `yaml:"working-directory,omitempty"` WorkingDirectory string `yaml:"working-directory,omitempty"`
Shell string `yaml:"shell,omitempty"` Shell string `yaml:"shell,omitempty"`
Env yaml.Node `yaml:"env,omitempty"` Env yaml.Node `yaml:"env,omitempty"`
With yaml.Node `yaml:"with,omitempty"` With yaml.Node `yaml:"with,omitempty"`
RawContinueOnError yaml.Node `yaml:"continue-on-error,omitempty"` // raw: the runner evaluates it with the steps context RawContinueOnError yaml.Node `yaml:"continue-on-error,omitempty"` // raw: the runner evaluates it with the steps context
TimeoutMinutes string `yaml:"timeout-minutes,omitempty"` TimeoutMinutes string `yaml:"timeout-minutes,omitempty"`
} }
// UnmarshalYAML canonicalizes booleans like continue-on-error // UnmarshalYAML canonicalizes booleans like continue-on-error
@@ -232,9 +207,9 @@ func (s *Step) String() string {
} }
return (&model.Step{ return (&model.Step{
ID: s.ID, ID: s.ID,
Name: string(s.Name), Name: s.Name,
Uses: s.Uses, Uses: s.Uses,
Run: string(s.Run), Run: s.Run,
}).String() }).String()
} }
+36 -2
View File
@@ -48,7 +48,7 @@ jobs:
_, origJob := sws[0].Job() _, origJob := sws[0].Job()
require.Len(t, origJob.Steps, 1) require.Len(t, origJob.Steps, 1)
const wantRun = "\n\necho start\necho done\n" const wantRun = "\n\necho start\necho done\n"
require.Equal(t, wantRun, string(origJob.Steps[0].Run)) require.Equal(t, wantRun, origJob.Steps[0].Run)
payload, err := sws[0].Marshal() payload, err := sws[0].Marshal()
require.NoError(t, err) require.NoError(t, err)
@@ -63,7 +63,7 @@ jobs:
// the round-trip must preserve the run block byte-for-byte // the round-trip must preserve the run block byte-for-byte
_, gotJob := roundTripped[0].Job() _, gotJob := roundTripped[0].Job()
require.Len(t, gotJob.Steps, 1) require.Len(t, gotJob.Steps, 1)
require.Equal(t, wantRun, string(gotJob.Steps[0].Run), "round-trip must preserve run content; got payload:\n%s", payload) require.Equal(t, wantRun, gotJob.Steps[0].Run, "round-trip must preserve run content; got payload:\n%s", payload)
} }
// Typing a step's continue-on-error as a bool used to reject the whole `jobs:` node. // Typing a step's continue-on-error as a bool used to reject the whole `jobs:` node.
@@ -104,3 +104,37 @@ jobs:
require.Equal(t, w, steps[i].RawContinueOnError, "step %d, payload:\n%s", i, payload) require.Equal(t, w, steps[i].RawContinueOnError, "step %d, payload:\n%s", i, payload)
} }
} }
// Folded (`>`) scalars in with/env used to gain blank lines and have spaces turned into newlines
// on every Marshal -> Parse cycle.
func TestSingleWorkflowRoundTripFoldedScalar(t *testing.T) {
const wf = `name: demo
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: ./.github/actions/build
with:
cfg: >
begin
first "indented" line with path dir/Installer Scripts/some long path/another long path/some file name.xzy,
second "indented" line
end
`
const want = "begin\n first \"indented\" line with path dir/Installer Scripts/some long path/another long path/some file name.xzy,\n second \"indented\" line\nend\n"
sws, err := Parse([]byte(wf))
require.NoError(t, err)
require.Len(t, sws, 1)
for range 3 {
payload, err := sws[0].Marshal()
require.NoError(t, err)
sws, err = Parse(payload)
require.NoError(t, err)
require.Len(t, sws, 1)
}
_, job := sws[0].Job()
var with map[string]string
require.NoError(t, job.Steps[0].With.Decode(&with))
require.Equal(t, want, with["cfg"])
}
@@ -7,5 +7,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- id: extract_tag - id: extract_tag
name: "\nExtract tag for variant" name: |-
run: "\necho Test\n"
Extract tag for variant
run: |
echo Test
+18 -8
View File
@@ -532,7 +532,7 @@ func matchIssuesEvent(issuePayload *api.IssuePayload, evt *jobparser.Event) bool
} }
for _, val := range vals { for _, val := range vals {
if slices.ContainsFunc(actions, glob.MustCompile(val, '/').Match) { if matchActivityType(val, actions...) {
matchTimes++ matchTimes++
break break
} }
@@ -575,7 +575,7 @@ func matchPullRequestEvent(ctx context.Context, gitRepo *git.Repository, commit
} }
log.Trace("matching pull_request %s with %v", action, vals) log.Trace("matching pull_request %s with %v", action, vals)
for _, val := range vals { for _, val := range vals {
if glob.MustCompile(val, '/').Match(string(action)) { if matchActivityType(val, string(action)) {
activityTypeMatched = true activityTypeMatched = true
matchTimes++ matchTimes++
break break
@@ -678,7 +678,7 @@ func matchIssueCommentEvent(issueCommentPayload *api.IssueCommentPayload, evt *j
// NONE // NONE
for _, val := range vals { for _, val := range vals {
if glob.MustCompile(val, '/').Match(string(issueCommentPayload.Action)) { if matchActivityType(val, string(issueCommentPayload.Action)) {
matchTimes++ matchTimes++
break break
} }
@@ -718,7 +718,7 @@ func matchPullRequestReviewEvent(prPayload *api.PullRequestPayload, evt *jobpars
} }
for _, val := range vals { for _, val := range vals {
if slices.ContainsFunc(actions, glob.MustCompile(val, '/').Match) { if matchActivityType(val, actions...) {
matchTimes++ matchTimes++
break break
} }
@@ -758,7 +758,7 @@ func matchPullRequestReviewCommentEvent(prPayload *api.PullRequestPayload, evt *
} }
for _, val := range vals { for _, val := range vals {
if slices.ContainsFunc(actions, glob.MustCompile(val, '/').Match) { if matchActivityType(val, actions...) {
matchTimes++ matchTimes++
break break
} }
@@ -795,7 +795,7 @@ func matchReleaseEvent(payload *api.ReleasePayload, evt *jobparser.Event) bool {
action = "edited" action = "edited"
} }
for _, val := range vals { for _, val := range vals {
if glob.MustCompile(val, '/').Match(string(action)) { if matchActivityType(val, string(action)) {
matchTimes++ matchTimes++
break break
} }
@@ -832,7 +832,7 @@ func matchPackageEvent(payload *api.PackagePayload, evt *jobparser.Event) bool {
action = "published" action = "published"
} }
for _, val := range vals { for _, val := range vals {
if glob.MustCompile(val, '/').Match(string(action)) { if matchActivityType(val, string(action)) {
matchTimes++ matchTimes++
break break
} }
@@ -857,7 +857,7 @@ func matchWorkflowRunEvent(payload *api.WorkflowRunPayload, evt *jobparser.Event
case "types": case "types":
action := payload.Action action := payload.Action
for _, val := range vals { for _, val := range vals {
if glob.MustCompile(val, '/').Match(action) { if matchActivityType(val, action) {
matchTimes++ matchTimes++
break break
} }
@@ -893,3 +893,13 @@ func matchWorkflowRunEvent(payload *api.WorkflowRunPayload, evt *jobparser.Event
} }
return matchTimes == len(evt.Acts()) return matchTimes == len(evt.Acts())
} }
// matchActivityType treats an invalid user-provided pattern as no match instead of panicking
func matchActivityType(pattern string, actions ...string) bool {
g, err := glob.Compile(pattern, '/')
if err != nil {
log.Debug("invalid activity type pattern %q: %v", pattern, err)
return false
}
return slices.ContainsFunc(actions, g.Match)
}
+7
View File
@@ -205,6 +205,13 @@ func TestDetectMatched(t *testing.T) {
yamlOn: "on:\n release:\n types: [published]", yamlOn: "on:\n release:\n types: [published]",
expected: detectMatched, expected: detectMatched,
}, },
{
desc: "HookEventRelease(release) doesn't match an invalid activity type pattern",
triggedEvent: webhook_module.HookEventRelease,
payload: &api.ReleasePayload{Action: api.HookReleasePublished},
yamlOn: "on:\n release:\n types: [\"[\"]",
expected: detectNotApplicable,
},
{ {
desc: "HookEventPackage(package) `created` action doesn't match GithubEventRegistryPackage(registry_package) with `updated` activity type", desc: "HookEventPackage(package) `created` action doesn't match GithubEventRegistryPackage(registry_package) with `updated` activity type",
triggedEvent: webhook_module.HookEventPackage, triggedEvent: webhook_module.HookEventPackage,
+3 -3
View File
@@ -102,11 +102,11 @@ func NewEmbeddedFS(data []byte) fs.ReadDirFS {
efs := &embeddedFS{data: data, files: make(map[string]*embeddedFileInfo)} efs := &embeddedFS{data: data, files: make(map[string]*embeddedFileInfo)}
efs.meta = sync.OnceValue(func() *EmbeddedMeta { efs.meta = sync.OnceValue(func() *EmbeddedMeta {
var meta EmbeddedMeta var meta EmbeddedMeta
p := bytes.LastIndexByte(data, '\n') _, metaJSON, ok := bytes.CutLast(data, []byte{'\n'})
if p < 0 { if !ok {
return &meta return &meta
} }
if err := json.Unmarshal(data[p+1:], &meta); err != nil { if err := json.Unmarshal(metaJSON, &meta); err != nil {
panic("embedded file is not valid") panic("embedded file is not valid")
} }
return &meta return &meta
+8
View File
@@ -32,6 +32,14 @@ func Init() {
RPID: setting.AppDomain, RPID: setting.AppDomain,
RPOrigins: []string{appURL}, RPOrigins: []string{appURL},
AttestationPreference: protocol.PreferNoAttestation, // Gitea never verifies attestation AttestationPreference: protocol.PreferNoAttestation, // Gitea never verifies attestation
// WebKit (Safari on iOS and macOS) returns an unsolicited "appid" client
// extension output even when the relying party did not request it. Since the
// webauthn library upgrade to v0.18, the default policy fails such ceremonies
// ('Client returned the appid extension output which was not requested'), breaking
// every passkey and security-key login from Safari on iOS. Ignore unsolicited
// extension outputs, the same policy other relying parties adopted for the
// same WebKit behavior.
ExtensionsUnsolicitedOutputPolicy: protocol.UnsolicitedOutputPolicyIgnore,
}, },
} }
} }
+2
View File
@@ -8,6 +8,7 @@ import (
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
"github.com/go-webauthn/webauthn/protocol"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -22,4 +23,5 @@ func TestInit(t *testing.T) {
assert.Equal(t, setting.AppDomain, WebAuthn.Config.RPID) assert.Equal(t, setting.AppDomain, WebAuthn.Config.RPID)
assert.Equal(t, setting.AppName, WebAuthn.Config.RPDisplayName) assert.Equal(t, setting.AppName, WebAuthn.Config.RPDisplayName)
assert.Equal(t, rpOrigin, WebAuthn.Config.RPOrigins) assert.Equal(t, rpOrigin, WebAuthn.Config.RPOrigins)
assert.Equal(t, protocol.UnsolicitedOutputPolicyIgnore, WebAuthn.Config.ExtensionsUnsolicitedOutputPolicy)
} }
+11 -15
View File
@@ -125,6 +125,11 @@ func syncGitConfig(ctx context.Context) (err error) {
return err return err
} }
// Gitea used to write "90", which git reads as a date in 1990
if err := configUnsetAll(ctx, "gc.reflogExpire", "90"); err != nil {
return err
}
// Apply user's git config options last so they take precedence over builtin defaults // Apply user's git config options last so they take precedence over builtin defaults
for k, v := range setting.GitConfig.Options { for k, v := range setting.GitConfig.Options {
if err = configSet(ctx, strings.ToLower(k), v); err != nil { if err = configSet(ctx, strings.ToLower(k), v); err != nil {
@@ -162,7 +167,7 @@ func configSet(ctx context.Context, key, value string) error {
return nil return nil
} }
if _, _, err = gitcmd.NewCommand("config", "--global"). if _, _, err = gitcmd.NewCommand("config", "--global", "--replace-all").
AddDynamicArguments(key, value). AddDynamicArguments(key, value).
RunStdString(ctx); err != nil { RunStdString(ctx); err != nil {
return fmt.Errorf("failed to set git global config %s, err: %w", key, err) return fmt.Errorf("failed to set git global config %s, err: %w", key, err)
@@ -190,7 +195,7 @@ func configSetNonExist(ctx context.Context, key, value string) error {
} }
func configAddNonExist(ctx context.Context, key, value string) error { func configAddNonExist(ctx context.Context, key, value string) error {
_, _, err := gitcmd.NewCommand("config", "--global", "--get").AddDynamicArguments(key, regexp.QuoteMeta(value)).RunStdString(ctx) _, _, err := gitcmd.NewCommand("config", "--global", "--get").AddDynamicArguments(key, "^"+regexp.QuoteMeta(value)+"$").RunStdString(ctx)
if err == nil { if err == nil {
// already exist // already exist
return nil return nil
@@ -207,18 +212,9 @@ func configAddNonExist(ctx context.Context, key, value string) error {
} }
func configUnsetAll(ctx context.Context, key, value string) error { func configUnsetAll(ctx context.Context, key, value string) error {
_, _, err := gitcmd.NewCommand("config", "--global", "--get").AddDynamicArguments(key).RunStdString(ctx) _, _, err := gitcmd.NewCommand("config", "--global", "--unset-all").AddDynamicArguments(key, "^"+regexp.QuoteMeta(value)+"$").RunStdString(ctx)
if err == nil { if err != nil && !gitcmd.IsErrorExitCode(err, 5) { // 5: no such key or no matching value
// exist, need to remove return fmt.Errorf("failed to unset git global config %s, err: %w", key, err)
_, _, err = gitcmd.NewCommand("config", "--global", "--unset-all").AddDynamicArguments(key, regexp.QuoteMeta(value)).RunStdString(ctx)
if err != nil {
return fmt.Errorf("failed to unset git global config %s, err: %w", key, err)
}
return nil
} }
if gitcmd.IsErrorExitCode(err, 1) { return nil
// not exist
return nil
}
return fmt.Errorf("failed to get git config %s, err: %w", key, err)
} }
+6
View File
@@ -32,7 +32,9 @@ func TestGitConfig(t *testing.T) {
assert.NoError(t, configSetNonExist(ctx, "test.key-a", "val-a-changed")) assert.NoError(t, configSetNonExist(ctx, "test.key-a", "val-a-changed"))
assert.False(t, gitConfigContains("key-a = val-a-changed")) assert.False(t, gitConfigContains("key-a = val-a-changed"))
assert.NoError(t, configAddNonExist(ctx, "test.key-a", "val-a2"))
assert.NoError(t, configSet(ctx, "test.key-a", "val-a-changed")) assert.NoError(t, configSet(ctx, "test.key-a", "val-a-changed"))
assert.NoError(t, configUnsetAll(ctx, "test.key-a", "val-a"))
assert.True(t, gitConfigContains("key-a = val-a-changed")) assert.True(t, gitConfigContains("key-a = val-a-changed"))
assert.NoError(t, configAddNonExist(ctx, "test.key-b", "val-b")) assert.NoError(t, configAddNonExist(ctx, "test.key-b", "val-b"))
@@ -41,6 +43,8 @@ func TestGitConfig(t *testing.T) {
assert.NoError(t, configAddNonExist(ctx, "test.key-b", "val-2b")) assert.NoError(t, configAddNonExist(ctx, "test.key-b", "val-2b"))
assert.True(t, gitConfigContains("key-b = val-b")) assert.True(t, gitConfigContains("key-b = val-b"))
assert.True(t, gitConfigContains("key-b = val-2b")) assert.True(t, gitConfigContains("key-b = val-2b"))
assert.NoError(t, configAddNonExist(ctx, "test.key-b", "val"))
assert.True(t, gitConfigContains("key-b = val\n"))
assert.NoError(t, configUnsetAll(ctx, "test.key-b", "val-b")) assert.NoError(t, configUnsetAll(ctx, "test.key-b", "val-b"))
assert.False(t, gitConfigContains("key-b = val-b")) assert.False(t, gitConfigContains("key-b = val-b"))
@@ -60,8 +64,10 @@ func TestSyncGitConfig(t *testing.T) {
defer test.MockVariableValue(&setting.GitConfig)() defer test.MockVariableValue(&setting.GitConfig)()
assert.Empty(t, setting.GitConfig.Options) assert.Empty(t, setting.GitConfig.Options)
assert.NoError(t, configSet(t.Context(), "gc.reflogExpire", "90"))
assert.NoError(t, syncGitConfig(t.Context())) assert.NoError(t, syncGitConfig(t.Context()))
assert.True(t, gitConfigContains("commitGraph = true")) // builtin default config assert.True(t, gitConfigContains("commitGraph = true")) // builtin default config
assert.False(t, gitConfigContains("reflogExpire"))
setting.GitConfig.Options["sync-test.cfg-key-a"] = "CfgValA" setting.GitConfig.Options["sync-test.cfg-key-a"] = "CfgValA"
setting.GitConfig.Options["core.commitgraph"] = "false" setting.GitConfig.Options["core.commitgraph"] = "false"
+4
View File
@@ -27,6 +27,7 @@ type FastImportFile struct {
type FastImportCommit struct { type FastImportCommit struct {
Ref string Ref string
Parent string
Message string Message string
Files []FastImportFile Files []FastImportFile
@@ -72,6 +73,9 @@ func ForceFastImport(ctx context.Context, repo RepositoryFacade, commits []FastI
msg := util.IfZero(c.Message, fmt.Sprintf("test commit %d", i+1)) msg := util.IfZero(c.Message, fmt.Sprintf("test commit %d", i+1))
_, _ = fmt.Fprintf(buf, "data %d\n%s\n", len(msg), msg) _, _ = fmt.Fprintf(buf, "data %d\n%s\n", len(msg), msg)
if c.Parent != "" {
_, _ = fmt.Fprintf(buf, "from %s\n", c.Parent)
}
for _, f := range c.Files { for _, f := range c.Files {
mode := util.IfZero(f.Mode, EntryModeBlob) mode := util.IfZero(f.Mode, EntryModeBlob)
+1 -1
View File
@@ -21,7 +21,7 @@ import (
// merge base between commits. // merge base between commits.
func FetchRemoteCommit(ctx context.Context, repo, remoteRepo RepositoryFacade, commitID string) error { func FetchRemoteCommit(ctx context.Context, repo, remoteRepo RepositoryFacade, commitID string) error {
return LockWriteAndDo(ctx, repo, func(ctx context.Context) error { return LockWriteAndDo(ctx, repo, func(ctx context.Context) error {
return gitcmd.NewCommand("fetch", "--no-tags"). return gitcmd.NewCommand("fetch", "--no-tags", "--no-write-commit-graph").
AddDynamicArguments(gitrepo.RepoLocalPath(remoteRepo)). AddDynamicArguments(gitrepo.RepoLocalPath(remoteRepo)).
AddDynamicArguments(commitID). AddDynamicArguments(commitID).
WithRepo(repo).Run(ctx) WithRepo(repo).Run(ctx)
+11 -7
View File
@@ -56,13 +56,17 @@ func parseLsTreeLine(line []byte) (*LsTreeEntry, error) {
return nil, fmt.Errorf("invalid ls-tree output (invalid object id): %q, err: %w", line, err) return nil, fmt.Errorf("invalid ls-tree output (invalid object id): %q, err: %w", line, err)
} }
if len(entryName) > 0 && entryName[0] == '"' { entry.Name, err = UnquoteDiffFilePath(string(entryName))
entry.Name, err = strconv.Unquote(string(entryName)) if err != nil {
if err != nil { return nil, fmt.Errorf("invalid ls-tree output (invalid name): %q, err: %w", line, err)
return nil, fmt.Errorf("invalid ls-tree output (invalid name): %q, err: %w", line, err)
}
} else {
entry.Name = string(entryName)
} }
return entry, nil return entry, nil
} }
func UnquoteDiffFilePath(path string) (string, error) {
// ref: see the document of git config's "core.quotepath"
if !strings.HasPrefix(path, `"`) {
return path, nil
}
return strconv.Unquote(path)
}
+20 -2
View File
@@ -415,8 +415,26 @@ func (repo *Repository) GetCommitsFromIDs(ctx context.Context, commitIDs []strin
return commits return commits
} }
// IsCommitInBranch check if the commit is on the branch // IsCommitInRepo checks if the commit exists in the repository.
func IsCommitInRepo(ctx context.Context, repo gitrepo.RepositoryFacade, commitID string) (bool, error) {
// "--quiet" makes it exit with 1 and no output if the commit doesn't exist
_, _, err := gitcmd.NewCommand("rev-parse", "--verify", "--quiet").
AddDynamicArguments(commitID + "^{commit}").
WithRepo(repo).
RunStdString(ctx)
if gitcmd.IsErrorExitCode(err, 1) {
return false, nil
}
return err == nil, err
}
// IsCommitInBranch checks if the commit is on the branch.
// It returns false (no error) if the commit doesn't exist or isn't on the branch.
func IsCommitInBranch(ctx context.Context, repo gitrepo.RepositoryFacade, commitID, branch string) (r bool, err error) { func IsCommitInBranch(ctx context.Context, repo gitrepo.RepositoryFacade, commitID, branch string) (r bool, err error) {
if exist, err := IsCommitInRepo(ctx, repo, commitID); err != nil || !exist {
return false, err
}
stdout, _, err := gitcmd.NewCommand("branch", "--contains"). stdout, _, err := gitcmd.NewCommand("branch", "--contains").
AddDynamicArguments(commitID, branch). AddDynamicArguments(commitID, branch).
WithRepo(repo). WithRepo(repo).
@@ -424,7 +442,7 @@ func IsCommitInBranch(ctx context.Context, repo gitrepo.RepositoryFacade, commit
if err != nil { if err != nil {
return false, err return false, err
} }
return len(stdout) > 0, err return len(stdout) > 0, nil
} }
// GetCommitBranchStart returns the commit where the branch diverged // GetCommitBranchStart returns the commit where the branch diverged
+18
View File
@@ -70,6 +70,20 @@ func TestGetCommitWithBadCommitID(t *testing.T) {
assert.True(t, IsErrNotExist(err)) assert.True(t, IsErrNotExist(err))
} }
func TestIsCommitInRepo(t *testing.T) {
bareRepo1, err := OpenRepositoryLocal(t.Context(), filepath.Join(testReposDir, "repo1_bare"))
assert.NoError(t, err)
defer bareRepo1.Close()
exist, err := IsCommitInRepo(t.Context(), bareRepo1, "2839944139e0de9737a044f78b0e4b40d989a9e3")
assert.NoError(t, err)
assert.True(t, exist)
exist, err = IsCommitInRepo(t.Context(), bareRepo1, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
assert.NoError(t, err)
assert.False(t, exist)
}
func TestIsCommitInBranch(t *testing.T) { func TestIsCommitInBranch(t *testing.T) {
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
bareRepo1, err := OpenRepositoryLocal(t.Context(), bareRepo1Path) bareRepo1, err := OpenRepositoryLocal(t.Context(), bareRepo1Path)
@@ -83,6 +97,10 @@ func TestIsCommitInBranch(t *testing.T) {
result, err = IsCommitInBranch(t.Context(), bareRepo1, "2839944139e0de9737a044f78b0e4b40d989a9e3", "branch2") result, err = IsCommitInBranch(t.Context(), bareRepo1, "2839944139e0de9737a044f78b0e4b40d989a9e3", "branch2")
assert.NoError(t, err) assert.NoError(t, err)
assert.False(t, result) assert.False(t, result)
result, err = IsCommitInBranch(t.Context(), bareRepo1, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "branch1")
assert.NoError(t, err)
assert.False(t, result)
} }
func TestRepository_CommitsBetween(t *testing.T) { func TestRepository_CommitsBetween(t *testing.T) {
+1 -1
View File
@@ -177,7 +177,7 @@ func initGlobCompiler(g *globCompiler, pattern string, separators []rune) (Glob,
regex, err := regexp.Compile(g.regexpPattern) regex, err := regexp.Compile(g.regexpPattern)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to compile regexp: %w", err) return nil, fmt.Errorf("failed to compile glob regexp: %w", err)
} }
g.regexp = regex g.regexp = regex
+105
View File
@@ -0,0 +1,105 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package htmlutil
import (
"strings"
)
type SrcSetCandidate struct {
SrcLink string
SrcDesc []string
}
const (
asciiSpaces = " \t\n\r\f"
asciiSpacesAndComma = " \t\n\r\f,"
)
func ParseSrcset(srcset string) (candidates []SrcSetCandidate) {
// ref: https://html.spec.whatwg.org/multipage/images.html#parse-a-srcset-attribute
// It can't simply use "split" because comma can appear in URLs and descriptors can be parenthesized and contain commas.
extractLink := func() (string, bool) {
srcset = strings.TrimLeft(srcset, asciiSpacesAndComma)
if srcset == "" {
return "", false
}
end := strings.IndexAny(srcset, asciiSpaces)
if end == -1 {
end = len(srcset)
}
link := srcset[:end]
srcset = srcset[end:]
if strings.HasSuffix(link, ",") {
return strings.TrimRight(link, ","), false
}
return link, true
}
extractDesc := func() (string, bool) {
srcset = strings.TrimLeft(srcset, asciiSpaces)
if srcset == "" {
return "", false
}
inParentheses := false
for i := range len(srcset) {
c := srcset[i]
if inParentheses {
inParentheses = c != ')'
continue
}
if c == '(' {
inParentheses = true
continue
}
if strings.IndexByte(asciiSpacesAndComma, c) != -1 {
desc := srcset[:i]
srcset = srcset[i+1:]
return desc, c != ','
}
}
desc := srcset
srcset = ""
return desc, false
}
for {
link, hasDesc := extractLink()
if link == "" {
break
}
var descriptors []string
if hasDesc {
for {
desc, hasMoreDesc := extractDesc()
if desc != "" {
descriptors = append(descriptors, desc)
}
if !hasMoreDesc {
break
}
}
}
candidates = append(candidates, SrcSetCandidate{SrcLink: link, SrcDesc: descriptors})
}
return candidates
}
func EncodeSrcset(candidates []SrcSetCandidate) string {
var sb strings.Builder
for i, c := range candidates {
if i != 0 {
sb.WriteString(", ")
}
link := c.SrcLink
if prefix, ok := strings.CutSuffix(link, ","); ok {
link = prefix + "%2C"
}
sb.WriteString(link)
for _, d := range c.SrcDesc {
sb.WriteByte(' ')
sb.WriteString(d)
}
}
return sb.String()
}
+81
View File
@@ -0,0 +1,81 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package htmlutil
import (
"testing"
"gitea.dev/modules/util"
"github.com/stretchr/testify/assert"
)
func TestSrcset(t *testing.T) {
for _, test := range []struct {
name string
input string
candidates []SrcSetCandidate
encoded string
}{
{
name: "bare URL",
input: "image.png",
candidates: []SrcSetCandidate{{SrcLink: "image.png"}},
},
{
name: "bare URLs",
input: "a.png, b.png , c.png,",
candidates: []SrcSetCandidate{{SrcLink: "a.png"}, {SrcLink: "b.png"}, {SrcLink: "c.png"}},
encoded: "a.png, b.png, c.png",
},
{
name: "empty candidates",
input: ", ,\ta.png 1x,, ,b.png 2x, ,",
candidates: []SrcSetCandidate{{SrcLink: "a.png", SrcDesc: []string{"1x"}}, {SrcLink: "b.png", SrcDesc: []string{"2x"}}},
encoded: "a.png 1x, b.png 2x",
},
{
name: "trailing commas in URL",
input: "a.png,, b.png 2x",
candidates: []SrcSetCandidate{{SrcLink: "a.png"}, {SrcLink: "b.png", SrcDesc: []string{"2x"}}},
encoded: "a.png, b.png 2x",
},
{
name: "comma in URL",
input: "comma,in,url.png",
candidates: []SrcSetCandidate{{SrcLink: "comma,in,url.png"}},
},
{
name: "embedded data URL",
input: "data:image/png;base64,abcd 1x, image.png 2x",
candidates: []SrcSetCandidate{{SrcLink: "data:image/png;base64,abcd", SrcDesc: []string{"1x"}}, {SrcLink: "image.png", SrcDesc: []string{"2x"}}},
},
{
name: "parenthesized descriptor",
input: "a.png (x,y) 1x, b.png 2x",
candidates: []SrcSetCandidate{{SrcLink: "a.png", SrcDesc: []string{"(x,y)", "1x"}}, {SrcLink: "b.png", SrcDesc: []string{"2x"}}},
},
} {
t.Run(test.name, func(t *testing.T) {
candidates := ParseSrcset(test.input)
assert.Equal(t, test.candidates, candidates)
encoded := EncodeSrcset(candidates)
assert.Equal(t, util.IfZero(test.encoded, test.input), encoded)
})
}
t.Run("encode trailing commas", func(t *testing.T) {
encoded := EncodeSrcset([]SrcSetCandidate{
{SrcLink: "image,"},
{SrcLink: "image,,", SrcDesc: []string{"2x"}},
{SrcLink: "image.svg?variant=light,", SrcDesc: []string{"3x"}},
})
assert.Equal(t, "image%2C, image,%2C 2x, image.svg?variant=light%2C 3x", encoded)
assert.Equal(t, []SrcSetCandidate{
{SrcLink: "image%2C"},
{SrcLink: "image,%2C", SrcDesc: []string{"2x"}},
{SrcLink: "image.svg?variant=light%2C", SrcDesc: []string{"3x"}},
}, ParseSrcset(encoded))
})
}
+5 -1
View File
@@ -8,12 +8,16 @@ import (
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestNewClient(t *testing.T) { func TestNewClient(t *testing.T) {
u, _ := url.Parse("file:///test") u, _ := url.Parse("file:///test")
c := newClient(u, nil) c := newClient(u, nil)
assert.IsType(t, &FilesystemClient{}, c) require.IsType(t, &FilesystemClient{}, c)
localPath, err := c.(*FilesystemClient).objectPath("invalid") //nolint:forcetypeassert // type is expected
assert.Empty(t, localPath)
assert.ErrorIs(t, err, ErrInvalidOIDFormat)
u, _ = url.Parse("https://test.com/lfs") u, _ = url.Parse("https://test.com/lfs")
c = newClient(u, nil) c = newClient(u, nil)
+15 -7
View File
@@ -18,7 +18,7 @@ type FilesystemClient struct {
lfsDir string lfsDir string
} }
// BatchSize returns the preferred size of batchs to process // BatchSize returns the preferred size of batches to process
func (c *FilesystemClient) BatchSize() int { func (c *FilesystemClient) BatchSize() int {
return 1 return 1
} }
@@ -29,8 +29,12 @@ func newFilesystemClient(endpoint *url.URL) *FilesystemClient {
return &FilesystemClient{lfsDir} return &FilesystemClient{lfsDir}
} }
func (c *FilesystemClient) objectPath(oid string) string { func (c *FilesystemClient) objectPath(oid string) (string, error) {
return filepath.Join(c.lfsDir, oid[0:2], oid[2:4], oid) if !isValidOid(oid) {
return "", ErrInvalidOIDFormat
}
// HINT: LFS-INTERNAL-PATH-PATTERN
return filepath.Join(c.lfsDir, oid[0:2], oid[2:4], oid), nil
} }
// Download reads the specific LFS object from the target path // Download reads the specific LFS object from the target path
@@ -38,8 +42,10 @@ func (c *FilesystemClient) Download(ctx context.Context, objects []Pointer, call
for _, object := range objects { for _, object := range objects {
p := Pointer{object.Oid, object.Size} p := Pointer{object.Oid, object.Size}
objectPath := c.objectPath(p.Oid) objectPath, err := c.objectPath(p.Oid)
if err != nil {
return err
}
f, err := os.Open(objectPath) f, err := os.Open(objectPath)
if err != nil { if err != nil {
return err return err
@@ -57,8 +63,10 @@ func (c *FilesystemClient) Upload(ctx context.Context, objects []Pointer, callba
for _, object := range objects { for _, object := range objects {
p := Pointer{object.Oid, object.Size} p := Pointer{object.Oid, object.Size}
objectPath := c.objectPath(p.Oid) objectPath, err := c.objectPath(p.Oid)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(objectPath), os.ModePerm); err != nil { if err := os.MkdirAll(filepath.Dir(objectPath), os.ModePerm); err != nil {
return err return err
} }
+25 -11
View File
@@ -10,9 +10,11 @@ import (
"fmt" "fmt"
"io" "io"
"path" "path"
"regexp"
"strconv" "strconv"
"strings" "strings"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
) )
// spec: https://github.com/git-lfs/git-lfs/blob/master/docs/spec.md // spec: https://github.com/git-lfs/git-lfs/blob/master/docs/spec.md
@@ -47,8 +49,6 @@ func ReadPointer(reader io.Reader) (Pointer, error) {
return ReadPointerFromBuffer(buf) return ReadPointerFromBuffer(buf)
} }
var oidPattern = regexp.MustCompile(`^[a-f\d]{64}$`)
// ReadPointerFromBuffer will return a pointer if the provided byte slice is a pointer file or an error otherwise. // ReadPointerFromBuffer will return a pointer if the provided byte slice is a pointer file or an error otherwise.
func ReadPointerFromBuffer(buf []byte) (Pointer, error) { func ReadPointerFromBuffer(buf []byte) (Pointer, error) {
var p Pointer var p Pointer
@@ -65,7 +65,7 @@ func ReadPointerFromBuffer(buf []byte) (Pointer, error) {
// spec says "key/value pairs MUST be sorted alphabetically in ascending order (version is exception and must be the first)" // spec says "key/value pairs MUST be sorted alphabetically in ascending order (version is exception and must be the first)"
oid := strings.TrimPrefix(splitLines[1], MetaFileOidPrefix) oid := strings.TrimPrefix(splitLines[1], MetaFileOidPrefix)
if len(oid) != 64 || !oidPattern.MatchString(oid) { if !isValidOid(oid) {
return p, ErrInvalidOIDFormat return p, ErrInvalidOIDFormat
} }
size, err := strconv.ParseInt(strings.TrimPrefix(splitLines[2], "size "), 10, 64) size, err := strconv.ParseInt(strings.TrimPrefix(splitLines[2], "size "), 10, 64)
@@ -79,13 +79,24 @@ func ReadPointerFromBuffer(buf []byte) (Pointer, error) {
return p, nil return p, nil
} }
func isValidOid(oid string) bool {
if len(oid) != 64 {
return false
}
for i := range len(oid) {
c := oid[i]
valid := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')
if !valid {
return false
}
}
return true
}
// IsValid checks if the pointer has a valid structure. // IsValid checks if the pointer has a valid structure.
// It doesn't check if the pointed-to-content exists. // It doesn't check if the pointed-to-content exists.
func (p Pointer) IsValid() bool { func (p Pointer) IsValid() bool {
if len(p.Oid) != 64 { if !isValidOid(p.Oid) {
return false
}
if !oidPattern.MatchString(p.Oid) {
return false return false
} }
if p.Size < 0 { if p.Size < 0 {
@@ -102,10 +113,13 @@ func (p Pointer) StringContent() string {
// RelativePath returns the relative storage path of the pointer // RelativePath returns the relative storage path of the pointer
func (p Pointer) RelativePath() string { func (p Pointer) RelativePath() string {
if len(p.Oid) < 5 { if !isValidOid(p.Oid) {
return p.Oid setting.PanicInDevOrTesting("invalid LFS OID: %s", p.Oid)
return "invalid-lfs-oid-" + util.PathJoinRelX(p.Oid)
} }
// HINT: LFS-INTERNAL-PATH-PATTERN: this path pattern is only used by Gitea internally,
// It isn't the same as the path pattern used by Git LFS, which is: <oid[0:2]>/<oid[2:4]>/<oid>
// However, there are other parts of Gitea that rely on such path pattern.
return path.Join(p.Oid[0:2], p.Oid[2:4], p.Oid[4:]) return path.Join(p.Oid[0:2], p.Oid[2:4], p.Oid[4:])
} }
+6 -1
View File
@@ -8,6 +8,9 @@ import (
"strings" "strings"
"testing" "testing"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -22,8 +25,10 @@ func TestRelativePath(t *testing.T) {
expected := path.Join("4d", "7a", "214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393") expected := path.Join("4d", "7a", "214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393")
assert.Equal(t, expected, p.RelativePath()) assert.Equal(t, expected, p.RelativePath())
defer test.MockVariableValue(&setting.IsProd, true)()
defer test.MockVariableValue(&setting.IsInTesting, false)()
p2 := Pointer{Oid: "4d7a"} p2 := Pointer{Oid: "4d7a"}
assert.Equal(t, "4d7a", p2.RelativePath()) assert.Equal(t, "invalid-lfs-oid-4d7a", p2.RelativePath())
} }
func TestIsValid(t *testing.T) { func TestIsValid(t *testing.T) {
+1 -1
View File
@@ -36,7 +36,7 @@ func b64encode(data []byte) string {
func camoHandleLink(link string) string { func camoHandleLink(link string) string {
if setting.Camo.Enabled { if setting.Camo.Enabled {
lnkURL, err := url.Parse(link) lnkURL, err := url.Parse(link)
if err == nil && lnkURL.IsAbs() && !strings.HasPrefix(link, setting.AppURL) && if err == nil && (lnkURL.Scheme == "http" || lnkURL.Scheme == "https") && !strings.HasPrefix(link, setting.AppURL) &&
(setting.Camo.Always || lnkURL.Scheme != "https") { (setting.Camo.Always || lnkURL.Scheme != "https") {
return CamoEncode(link) return CamoEncode(link)
} }
+4
View File
@@ -15,8 +15,11 @@ func TestCamoHandleLink(t *testing.T) {
setting.AppURL = "https://gitea.com" setting.AppURL = "https://gitea.com"
// Test media proxy // Test media proxy
setting.Camo.Enabled = true setting.Camo.Enabled = true
setting.Camo.Always = false
setting.Camo.ServerURL = "https://image.proxy" setting.Camo.ServerURL = "https://image.proxy"
setting.Camo.HMACKey = "geheim" setting.Camo.HMACKey = "geheim"
dataURL := "data:image/png;base64,i//V"
assert.Equal(t, dataURL, camoHandleLink(dataURL))
assert.Equal(t, assert.Equal(t,
"https://gitea.com/img.jpg", "https://gitea.com/img.jpg",
@@ -29,6 +32,7 @@ func TestCamoHandleLink(t *testing.T) {
camoHandleLink("http://testimages.org/img.jpg")) camoHandleLink("http://testimages.org/img.jpg"))
setting.Camo.Always = true setting.Camo.Always = true
assert.Equal(t, dataURL, camoHandleLink(dataURL))
assert.Equal(t, assert.Equal(t,
"https://gitea.com/img.jpg", "https://gitea.com/img.jpg",
camoHandleLink("https://gitea.com/img.jpg")) camoHandleLink("https://gitea.com/img.jpg"))
+4 -1
View File
@@ -355,6 +355,8 @@ func visitNode(ctx *RenderContext, procs []processor, node *html.Node) *html.Nod
return visitNodeImg(ctx, node) return visitNodeImg(ctx, node)
} else if node.Data == "video" { } else if node.Data == "video" {
return visitNodeVideo(ctx, node) return visitNodeVideo(ctx, node)
} else if node.Data == "source" {
return visitNodePictureSource(ctx, node)
} }
if node.Data == "a" { if node.Data == "a" {
@@ -363,10 +365,11 @@ func visitNode(ctx *RenderContext, procs []processor, node *html.Node) *html.Nod
// because the content there is not processable, for example: the content is a commit id or a full URL. // because the content there is not processable, for example: the content is a commit id or a full URL.
procs = emojiProcessors procs = emojiProcessors
} }
next := node.NextSibling
for n := node.FirstChild; n != nil; { for n := node.FirstChild; n != nil; {
n = visitNode(ctx, procs, n) n = visitNode(ctx, procs, n)
} }
return node.NextSibling return next
} }
// createKeyword() renders a highlighted version of an action keyword // createKeyword() renders a highlighted version of an action keyword
+32 -10
View File
@@ -6,6 +6,7 @@ package markup
import ( import (
"strings" "strings"
"gitea.dev/modules/htmlutil"
"gitea.dev/modules/markup/common" "gitea.dev/modules/markup/common"
"golang.org/x/net/html" "golang.org/x/net/html"
@@ -127,6 +128,10 @@ func processNodeA(ctx *RenderContext, node *html.Node) {
} }
} }
func resolveMediaLinkWithCamo(ctx *RenderContext, link string) string {
return camoHandleLink(ctx.RenderHelper.ResolveLink(link, LinkTypeMedia))
}
func visitNodeImg(ctx *RenderContext, img *html.Node) (next *html.Node) { func visitNodeImg(ctx *RenderContext, img *html.Node) (next *html.Node) {
next = img.NextSibling next = img.NextSibling
attrSrc, hasLazy := "", false attrSrc, hasLazy := "", false
@@ -152,19 +157,21 @@ func visitNodeImg(ctx *RenderContext, img *html.Node) (next *html.Node) {
cnt++ cnt++
} }
if isLinkable { if isLinkable {
linkNode := img
if img.Parent.Data == "picture" {
linkNode = img.Parent
}
wrapper := &html.Node{Type: html.ElementNode, Data: "a", Attr: []html.Attribute{ wrapper := &html.Node{Type: html.ElementNode, Data: "a", Attr: []html.Attribute{
{Key: "href", Val: ctx.RenderHelper.ResolveLink(imgSrcOrigin, LinkTypeDefault)}, {Key: "href", Val: ctx.RenderHelper.ResolveLink(imgSrcOrigin, LinkTypeDefault)},
{Key: "target", Val: "_blank"}, {Key: "target", Val: "_blank"},
}} }}
parent := img.Parent parent := linkNode.Parent
imgNext := img.NextSibling linkNext := linkNode.NextSibling
parent.RemoveChild(img) parent.RemoveChild(linkNode)
parent.InsertBefore(wrapper, imgNext) parent.InsertBefore(wrapper, linkNext)
wrapper.AppendChild(img) wrapper.AppendChild(linkNode)
} }
imgAttr.Val = resolveMediaLinkWithCamo(ctx, imgSrcOrigin)
imgAttr.Val = ctx.RenderHelper.ResolveLink(imgSrcOrigin, LinkTypeMedia)
imgAttr.Val = camoHandleLink(imgAttr.Val)
img.Attr[i] = imgAttr img.Attr[i] = imgAttr
} }
if !RenderBehaviorForTesting.DisableAdditionalAttributes && !hasLazy && !strings.HasPrefix(attrSrc, "data:") { if !RenderBehaviorForTesting.DisableAdditionalAttributes && !hasLazy && !strings.HasPrefix(attrSrc, "data:") {
@@ -179,9 +186,24 @@ func visitNodeVideo(ctx *RenderContext, node *html.Node) (next *html.Node) {
if attr.Key != "src" { if attr.Key != "src" {
continue continue
} }
attr.Val = ctx.RenderHelper.ResolveLink(attr.Val, LinkTypeMedia) attr.Val = resolveMediaLinkWithCamo(ctx, attr.Val)
attr.Val = camoHandleLink(attr.Val)
node.Attr[i] = attr node.Attr[i] = attr
} }
return next return next
} }
func visitNodePictureSource(ctx *RenderContext, node *html.Node) (next *html.Node) {
next = node.NextSibling
for attrIdx, attr := range node.Attr {
if attr.Key != "srcset" {
continue
}
srcsetCandidates := htmlutil.ParseSrcset(attr.Val)
for i, candidate := range srcsetCandidates {
srcsetCandidates[i].SrcLink = resolveMediaLinkWithCamo(ctx, candidate.SrcLink)
}
attr.Val = htmlutil.EncodeSrcset(srcsetCandidates)
node.Attr[attrIdx] = attr
}
return next
}
+1 -1
View File
@@ -42,7 +42,7 @@ func resolveLinkRelative(ctx context.Context, base, cur, link string, absolute b
finalLink += "?" + linkURL.RawQuery finalLink += "?" + linkURL.RawQuery
} }
if linkURL.Fragment != "" { if linkURL.Fragment != "" {
finalLink += "#" + linkURL.Fragment finalLink += "#" + linkURL.EscapedFragment()
} }
} }
+1 -1
View File
@@ -20,7 +20,7 @@ func TestResolveLinkRelative(t *testing.T) {
assert.Equal(t, "/a/c", resolveLinkRelative(ctx, "/a", "b", "/c", false)) assert.Equal(t, "/a/c", resolveLinkRelative(ctx, "/a", "b", "/c", false))
assert.Equal(t, "/a/c#id", resolveLinkRelative(ctx, "/a", "b", "/c#id", false)) assert.Equal(t, "/a/c#id", resolveLinkRelative(ctx, "/a", "b", "/c#id", false))
assert.Equal(t, "/a/%2f?k=/", resolveLinkRelative(ctx, "/a", "b", "/%2f/?k=/", false)) assert.Equal(t, "/a/%2f?k=/", resolveLinkRelative(ctx, "/a", "b", "/%2f/?k=/", false))
assert.Equal(t, "/a/b/c?k=v#id", resolveLinkRelative(ctx, "/a", "b", "c/?k=v#id", false)) assert.Equal(t, "/a/b/c?k=v#with%20space", resolveLinkRelative(ctx, "/a", "b", "c/?k=v#with%20space", false))
assert.Equal(t, "%invalid", resolveLinkRelative(ctx, "/a", "b", "%invalid", false)) assert.Equal(t, "%invalid", resolveLinkRelative(ctx, "/a", "b", "%invalid", false))
assert.Equal(t, "http://localhost:3000/a", resolveLinkRelative(ctx, "/a", "", "", true)) assert.Equal(t, "http://localhost:3000/a", resolveLinkRelative(ctx, "/a", "", "", true))
+4 -1
View File
@@ -12,6 +12,7 @@ import (
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"io" "io"
"net/url"
"regexp" "regexp"
"strings" "strings"
"sync" "sync"
@@ -343,7 +344,9 @@ func parseUploadPackage(upload *packageUpload) (*Package, error) {
p.DistTags = append(p.DistTags, tag) p.DistTags = append(p.DistTags, tag)
} }
p.Filename = strings.ToLower(fmt.Sprintf("%s-%s.tgz", name, p.Version)) filename := fmt.Sprintf("%s-%s.tgz", name, p.Version)
p.Filename = strings.ToLower(filename)
p.Metadata.GiteaTarballPath = util.PathEscapeSegments(p.Name) + "/-/" + url.PathEscape(filename) // canonical URL clients derive from name and version, file lookups ignore case
attachment := upload.Attachments[meta.Name+"-"+meta.Version+".tgz"] // not the sigstore bundle of `npm publish --provenance` attachment := upload.Attachments[meta.Name+"-"+meta.Version+".tgz"] // not the sigstore bundle of `npm publish --provenance`
if attachment == nil && len(upload.Attachments) == 1 { if attachment == nil && len(upload.Attachments) == 1 {
+3 -2
View File
@@ -23,7 +23,7 @@ func TestParsePackage(t *testing.T) {
packageScope := "@scope" packageScope := "@scope"
packageName := "test-package" packageName := "test-package"
packageFullName := packageScope + "/" + packageName packageFullName := packageScope + "/" + packageName
packageVersion := "1.0.1-pre" packageVersion := "1.0.1-Pre"
packageTag := "latest" packageTag := "latest"
packageAuthor := "KN4CK3R" packageAuthor := "KN4CK3R"
packageBin := "gitea" packageBin := "gitea"
@@ -287,7 +287,8 @@ func TestParsePackage(t *testing.T) {
assert.Equal(t, packageFullName, p.Name) assert.Equal(t, packageFullName, p.Name)
assert.Equal(t, packageVersion, p.Version) assert.Equal(t, packageVersion, p.Version)
assert.Equal(t, []string{packageTag}, p.DistTags) assert.Equal(t, []string{packageTag}, p.DistTags)
assert.Equal(t, fmt.Sprintf("%s-%s.tgz", strings.Split(packageFullName, "/")[1], packageVersion), p.Filename) assert.Equal(t, strings.ToLower(fmt.Sprintf("%s-%s.tgz", packageName, packageVersion)), p.Filename)
assert.Equal(t, fmt.Sprintf("%s/-/%s-%s.tgz", packageFullName, packageName, packageVersion), p.Metadata.GiteaTarballPath)
b, _ = base64.StdEncoding.DecodeString(data) b, _ = base64.StdEncoding.DecodeString(data)
assert.Equal(t, b, p.Data) assert.Equal(t, b, p.Data)
assert.Equal(t, packageName, p.Metadata.Name) assert.Equal(t, packageName, p.Metadata.Name)
+2
View File
@@ -34,4 +34,6 @@ type Metadata struct {
Funding any `json:"funding,omitempty"` Funding any `json:"funding,omitempty"`
AcceptDependencies map[string]string `json:"accept_dependencies,omitempty"` AcceptDependencies map[string]string `json:"accept_dependencies,omitempty"`
Deprecated string `json:"deprecated,omitempty"` Deprecated string `json:"deprecated,omitempty"`
GiteaTarballPath string `json:"gitea_tarball_path,omitempty"` // Gitea-internal, fixed at upload because lockfiles pin it
} }
+2 -2
View File
@@ -38,14 +38,14 @@ func ReloadTemplates(ctx context.Context) ResponseExtra {
// FlushOptions represents the options for the flush call // FlushOptions represents the options for the flush call
type FlushOptions struct { type FlushOptions struct {
Timeout time.Duration Timeout int64
NonBlocking bool NonBlocking bool
} }
// FlushQueues calls the internal flush-queues function // FlushQueues calls the internal flush-queues function
func FlushQueues(ctx context.Context, timeout time.Duration, nonBlocking bool) ResponseExtra { func FlushQueues(ctx context.Context, timeout time.Duration, nonBlocking bool) ResponseExtra {
reqURL := setting.LocalURL + "api/internal/manager/flush-queues" reqURL := setting.LocalURL + "api/internal/manager/flush-queues"
req := newInternalRequestAPI(ctx, reqURL, "POST", FlushOptions{Timeout: timeout, NonBlocking: nonBlocking}) req := newInternalRequestAPI(ctx, reqURL, "POST", FlushOptions{Timeout: int64(timeout), NonBlocking: nonBlocking})
if timeout > 0 { if timeout > 0 {
req.SetReadWriteTimeout(timeout + 10*time.Second) req.SetReadWriteTimeout(timeout + 10*time.Second)
} }
+2 -3
View File
@@ -89,7 +89,6 @@ func loadGitFrom(rootCfg ConfigProvider) {
GitConfig.Options = make(map[string]string) GitConfig.Options = make(map[string]string)
GitConfig.SetOption("diff.algorithm", "histogram") GitConfig.SetOption("diff.algorithm", "histogram")
GitConfig.SetOption("core.logAllRefUpdates", "true") GitConfig.SetOption("core.logAllRefUpdates", "true")
GitConfig.SetOption("gc.reflogExpire", "90")
secGitReflog := rootCfg.Section("git.reflog") secGitReflog := rootCfg.Section("git.reflog")
if secGitReflog.HasKey("ENABLED") { if secGitReflog.HasKey("ENABLED") {
@@ -97,8 +96,8 @@ func loadGitFrom(rootCfg ConfigProvider) {
GitConfig.SetOption("core.logAllRefUpdates", secGitReflog.Key("ENABLED").In("true", []string{"true", "false"})) GitConfig.SetOption("core.logAllRefUpdates", secGitReflog.Key("ENABLED").In("true", []string{"true", "false"}))
} }
if secGitReflog.HasKey("EXPIRATION") { if secGitReflog.HasKey("EXPIRATION") {
deprecatedSetting(rootCfg, "git.reflog", "EXPIRATION", "git.config", "core.reflogExpire", "1.21") deprecatedSetting(rootCfg, "git.reflog", "EXPIRATION", "git.config", "gc.reflogExpire", "1.21")
GitConfig.SetOption("gc.reflogExpire", secGitReflog.Key("EXPIRATION").String()) GitConfig.SetOption("gc.reflogExpire", secGitReflog.Key("EXPIRATION").String()+".days")
} }
for _, key := range secGitConfig.Keys() { for _, key := range secGitConfig.Keys() {
+4 -4
View File
@@ -38,8 +38,8 @@ diff.algorithm = other
} }
func TestGitReflog(t *testing.T) { func TestGitReflog(t *testing.T) {
defer test.MockVariableValue(&Git) defer test.MockVariableValue(&Git)()
defer test.MockVariableValue(&GitConfig) defer test.MockVariableValue(&GitConfig)()
// default reflog config without legacy options // default reflog config without legacy options
cfg, err := NewConfigProviderFromData(``) cfg, err := NewConfigProviderFromData(``)
@@ -47,7 +47,7 @@ func TestGitReflog(t *testing.T) {
loadGitFrom(cfg) loadGitFrom(cfg)
assert.Equal(t, "true", GitConfig.GetOption("core.logAllRefUpdates")) assert.Equal(t, "true", GitConfig.GetOption("core.logAllRefUpdates"))
assert.Equal(t, "90", GitConfig.GetOption("gc.reflogExpire")) assert.Empty(t, GitConfig.GetOption("gc.reflogExpire"))
// custom reflog config by legacy options // custom reflog config by legacy options
cfg, err = NewConfigProviderFromData(` cfg, err = NewConfigProviderFromData(`
@@ -59,5 +59,5 @@ EXPIRATION = 123
loadGitFrom(cfg) loadGitFrom(cfg)
assert.Equal(t, "false", GitConfig.GetOption("core.logAllRefUpdates")) assert.Equal(t, "false", GitConfig.GetOption("core.logAllRefUpdates"))
assert.Equal(t, "123", GitConfig.GetOption("gc.reflogExpire")) assert.Equal(t, "123.days", GitConfig.GetOption("gc.reflogExpire"))
} }
+1 -1
View File
@@ -108,7 +108,7 @@ var OAuth2 = struct {
JWTSigningAlgorithm: "RS256", JWTSigningAlgorithm: "RS256",
JWTSigningPrivateKeyFile: "jwt/private.pem", JWTSigningPrivateKeyFile: "jwt/private.pem",
MaxTokenLength: math.MaxInt16, MaxTokenLength: math.MaxInt16,
DefaultApplications: []string{"git-credential-oauth", "git-credential-manager", "tea", "gitea-app"}, DefaultApplications: []string{"git-credential-oauth", "git-credential-manager", "tea", "gitea-app", "gitea-vscode"},
} }
func loadOAuth2From(rootCfg ConfigProvider) { func loadOAuth2From(rootCfg ConfigProvider) {
+1 -1
View File
@@ -62,7 +62,7 @@ func TestGetGeneralSigningSecretSave(t *testing.T) {
func TestOauth2DefaultApplications(t *testing.T) { func TestOauth2DefaultApplications(t *testing.T) {
cfg, _ := NewConfigProviderFromData(``) cfg, _ := NewConfigProviderFromData(``)
loadOAuth2From(cfg) loadOAuth2From(cfg)
assert.Equal(t, []string{"git-credential-oauth", "git-credential-manager", "tea", "gitea-app"}, OAuth2.DefaultApplications) assert.Equal(t, []string{"git-credential-oauth", "git-credential-manager", "tea", "gitea-app", "gitea-vscode"}, OAuth2.DefaultApplications)
cfg, _ = NewConfigProviderFromData(`[oauth2] cfg, _ = NewConfigProviderFromData(`[oauth2]
DEFAULT_APPLICATIONS = tea DEFAULT_APPLICATIONS = tea
+1 -1
View File
@@ -303,7 +303,7 @@ func loadRepositoryFrom(rootCfg ConfigProvider) {
sec := rootCfg.Section("repository") sec := rootCfg.Section("repository")
Repository.DisableHTTPGit = sec.Key("DISABLE_HTTP_GIT").MustBool() Repository.DisableHTTPGit = sec.Key("DISABLE_HTTP_GIT").MustBool()
Repository.UseCompatSSHURI = sec.Key("USE_COMPAT_SSH_URI").MustBool() Repository.UseCompatSSHURI = sec.Key("USE_COMPAT_SSH_URI").MustBool()
Repository.GoGetCloneURLProtocol = sec.Key("GO_GET_CLONE_URL_PROTOCOL").MustString("https") Repository.GoGetCloneURLProtocol = sec.Key("GO_GET_CLONE_URL_PROTOCOL").String()
// MAX_CREATION_LIMIT is a shortcut that sets the default for the two per-type limits below. // MAX_CREATION_LIMIT is a shortcut that sets the default for the two per-type limits below.
// USER_/ORG_MAX_CREATION_LIMIT take precedence when explicitly set. // USER_/ORG_MAX_CREATION_LIMIT take precedence when explicitly set.
Repository.MaxCreationLimit = sec.Key("MAX_CREATION_LIMIT").MustInt(-1) // FIXME: INI-MUST-SIDE-EFFECT Repository.MaxCreationLimit = sec.Key("MAX_CREATION_LIMIT").MustInt(-1) // FIXME: INI-MUST-SIDE-EFFECT
+11 -7
View File
@@ -14,8 +14,6 @@ import (
"gitea.dev/modules/glob" "gitea.dev/modules/glob"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
"golang.org/x/net/idna"
) )
type globalVarsStruct struct { type globalVarsStruct struct {
@@ -24,6 +22,8 @@ type globalVarsStruct struct {
invalidUsernamePattern *regexp.Regexp invalidUsernamePattern *regexp.Regexp
validBadgeSlugPattern *regexp.Regexp validBadgeSlugPattern *regexp.Regexp
invalidBadgeSlugPattern *regexp.Regexp invalidBadgeSlugPattern *regexp.Regexp
validEmailHostName *regexp.Regexp
validEmailHostIP *regexp.Regexp
} }
var globalVars = sync.OnceValue(func() *globalVarsStruct { var globalVars = sync.OnceValue(func() *globalVarsStruct {
@@ -33,6 +33,8 @@ var globalVars = sync.OnceValue(func() *globalVarsStruct {
invalidUsernamePattern: regexp.MustCompile(`[-._]{2,}|[-._]$`), // No consecutive or trailing non-alphanumeric chars invalidUsernamePattern: regexp.MustCompile(`[-._]{2,}|[-._]$`), // No consecutive or trailing non-alphanumeric chars
validBadgeSlugPattern: regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`), validBadgeSlugPattern: regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`),
invalidBadgeSlugPattern: regexp.MustCompile(`[-._]{2,}|[-._]$`), invalidBadgeSlugPattern: regexp.MustCompile(`[-._]{2,}|[-._]$`),
validEmailHostName: regexp.MustCompile(`^[a-zA-Z0-9][-.\w]*$`),
validEmailHostIP: regexp.MustCompile(`(?i)^\[([0-9.]+|ipv6:[0-9a-f:.]+)\]$`),
} }
}) })
@@ -124,10 +126,12 @@ func IsEmailAddressValid(email string) bool {
return false return false
} }
_, domain, _ := strings.Cut(email, "@") _, domain, _ := strings.Cut(email, "@")
if strings.HasPrefix(domain, "[") { if !globalVars().validEmailHostName.MatchString(domain) && !globalVars().validEmailHostIP.MatchString(domain) {
// address like "foo@[192.168.1.2]" return false
return true
} }
_, err = idna.Registration.ToASCII(domain) if strings.HasPrefix(domain, "-") || strings.HasSuffix(domain, "-") ||
return err == nil strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") {
return false
}
return true
} }
+2 -2
View File
@@ -135,8 +135,8 @@ func shortenFilename(filename, fallback string) string {
if filename == "" { if filename == "" {
return fallback return fallback
} }
if lastIndex := strings.LastIndexByte(filename, '/'); lastIndex >= 0 { if dir, _, ok := strings.CutLast(filename, "/"); ok {
if secondLastIndex := strings.LastIndexByte(filename[:lastIndex], '/'); secondLastIndex >= 0 { if secondLastIndex := strings.LastIndexByte(dir, '/'); secondLastIndex >= 0 {
return filename[secondLastIndex+1:] return filename[secondLastIndex+1:]
} }
} }
+1 -1
View File
@@ -930,7 +930,7 @@
"settings.delete_account_title": "Delete User Account", "settings.delete_account_title": "Delete User Account",
"settings.delete_account_desc": "Are you sure you want to permanently delete this user account?", "settings.delete_account_desc": "Are you sure you want to permanently delete this user account?",
"settings.email_notifications.enable": "Enable Email Notifications", "settings.email_notifications.enable": "Enable Email Notifications",
"settings.email_notifications.onmention": "Only Email on Mention", "settings.email_notifications.onmention_and_custom": "Only Email on Mention and Custom Watches",
"settings.email_notifications.disable": "Disable Email Notifications", "settings.email_notifications.disable": "Disable Email Notifications",
"settings.email_notifications.submit": "Set Email Preference", "settings.email_notifications.submit": "Set Email Preference",
"settings.email_notifications.andyourown": "And Your Own Notifications", "settings.email_notifications.andyourown": "And Your Own Notifications",
+14 -4
View File
@@ -290,13 +290,22 @@ func (r *artifactV4Routes) findArtifactByName(ctx *ArtifactContext, runID int64,
return &art, nil return &art, nil
} }
// maxProtobufBodySize caps the Twirp request body size accepted by parseProtobufBody.
// These requests only ever carry a handful of small scalar fields (run IDs, artifact
// names, etc.), so this is far above any legitimate payload while still bounding memory use.
const maxProtobufBodySize = 1024 * 1024 // 1 MiB
func (r *artifactV4Routes) parseProtobufBody(ctx *ArtifactContext, req protoreflect.ProtoMessage) bool { func (r *artifactV4Routes) parseProtobufBody(ctx *ArtifactContext, req protoreflect.ProtoMessage) bool {
body, err := io.ReadAll(ctx.Req.Body) body, err := io.ReadAll(io.LimitReader(ctx.Req.Body, maxProtobufBodySize+1))
if err != nil { if err != nil {
log.Error("Error decode request body: %v", err) log.Error("Error decode request body: %v", err)
ctx.HTTPError(http.StatusInternalServerError, "Error decode request body") ctx.HTTPError(http.StatusInternalServerError, "Error decode request body")
return false return false
} }
if len(body) > maxProtobufBodySize {
ctx.HTTPError(http.StatusRequestEntityTooLarge, "request body too large")
return false
}
err = protojson.Unmarshal(body, req) err = protojson.Unmarshal(body, req)
if err != nil { if err != nil {
log.Error("Error decode request body: %v", err) log.Error("Error decode request body: %v", err)
@@ -646,9 +655,10 @@ func (r *artifactV4Routes) listArtifacts(ctx *ArtifactContext) {
Size: artifact.FileSize, Size: artifact.FileSize,
} }
} }
for _, artifact := range table { for _, artifact := range artifacts {
if artifact != nil { if item := table[artifact.ArtifactName]; item != nil {
list = append(list, artifact) list = append(list, item)
delete(table, artifact.ArtifactName)
} }
} }
+1 -1
View File
@@ -414,7 +414,7 @@ func CommonRoutes() *web.Router {
g.UseUnescapedPath() g.UseUnescapedPath()
g.MatchPath("DELETE", packageId+"/-/<version>/<filename>/-rev/<revision>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackageVersion) g.MatchPath("DELETE", packageId+"/-/<version>/<filename>/-rev/<revision>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackageVersion)
g.MatchPath("DELETE", packageId+"/-/<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+"/-/<version>/<filename>", npm.DownloadPackageFileByName) // 1.27 and 28.0 URL, still advertised for older uploads
g.MatchPath("GET", packageId+"/-/<filename>", npm.DownloadPackageFileByName) g.MatchPath("GET", packageId+"/-/<filename>", npm.DownloadPackageFileByName)
g.MatchPath("DELETE", packageId+"/-rev/<revision>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackage) g.MatchPath("DELETE", packageId+"/-rev/<revision>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackage)
g.MatchPath("PUT", packageId+"/-rev/<revision>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePreview) g.MatchPath("PUT", packageId+"/-rev/<revision>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePreview)
+7 -1
View File
@@ -8,6 +8,7 @@ import (
"encoding/base64" "encoding/base64"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"net/url"
"slices" "slices"
"sort" "sort"
"time" "time"
@@ -82,6 +83,11 @@ func createPackageMetadataVersion(registryURL string, pd *packages_model.Package
metadata := packages_model.DescriptorMetadata[*npm_module.Metadata](pd) metadata := packages_model.DescriptorMetadata[*npm_module.Metadata](pd)
tarballPath := metadata.GiteaTarballPath
if tarballPath == "" {
tarballPath = fmt.Sprintf("%s/-/%s/%s", url.QueryEscape(pd.Package.Name), url.PathEscape(pd.Version.Version), url.PathEscape(pd.Files[0].File.LowerName)) // 1.27 URL, pinned by older lockfiles
}
return &npm_module.PackageMetadataVersion{ return &npm_module.PackageMetadataVersion{
ID: fmt.Sprintf("%s@%s", pd.Package.Name, pd.Version.Version), ID: fmt.Sprintf("%s@%s", pd.Package.Name, pd.Version.Version),
Name: pd.Package.Name, Name: pd.Package.Name,
@@ -113,7 +119,7 @@ func createPackageMetadataVersion(registryURL string, pd *packages_model.Package
Dist: npm_module.PackageDistribution{ Dist: npm_module.PackageDistribution{
Shasum: pd.Files[0].Blob.HashSHA1, Shasum: pd.Files[0].Blob.HashSHA1,
Integrity: "sha512-" + base64.StdEncoding.EncodeToString(hashBytes), Integrity: "sha512-" + base64.StdEncoding.EncodeToString(hashBytes),
Tarball: fmt.Sprintf("%s/%s/-/%s", registryURL, pd.Package.Name, pd.Files[0].File.LowerName), // npmjs shape, which npm parses for allowScripts and yarn keeps registry-relative Tarball: registryURL + "/" + tarballPath,
}, },
} }
} }
+10 -6
View File
@@ -19,13 +19,13 @@ import (
func TestCreatePackageMetadataResponse(t *testing.T) { func TestCreatePackageMetadataResponse(t *testing.T) {
repository := npm_module.Repository{Type: "git", URL: "https://gitea.dev/alice/test.git"} repository := npm_module.Repository{Type: "git", URL: "https://gitea.dev/alice/test.git"}
descriptor := func(v string, publishedUnix int64, repo npm_module.Repository) *packages_model.PackageDescriptor { descriptor := func(v string, publishedUnix int64, repo npm_module.Repository, tarballPath string) *packages_model.PackageDescriptor {
return &packages_model.PackageDescriptor{ return &packages_model.PackageDescriptor{
Package: &packages_model.Package{Name: "@scope/test"}, Package: &packages_model.Package{Name: "@scope/test"},
Owner: &user_model.User{Name: "alice"}, Owner: &user_model.User{Name: "alice"},
Version: &packages_model.PackageVersion{Version: v, CreatedUnix: timeutil.TimeStamp(publishedUnix)}, Version: &packages_model.PackageVersion{Version: v, CreatedUnix: timeutil.TimeStamp(publishedUnix)},
SemVer: version.Must(version.NewVersion(v)), SemVer: version.Must(version.NewVersion(v)),
Metadata: &npm_module.Metadata{Readme: v, Keywords: []string{"gitea"}, Repository: repo}, Metadata: &npm_module.Metadata{Readme: v, Keywords: []string{"gitea"}, Repository: repo, GiteaTarballPath: tarballPath},
Files: []*packages_model.PackageFileDescriptor{{ Files: []*packages_model.PackageFileDescriptor{{
File: &packages_model.PackageFile{LowerName: "test-" + v + ".tgz"}, File: &packages_model.PackageFile{LowerName: "test-" + v + ".tgz"},
Blob: &packages_model.PackageBlob{}, Blob: &packages_model.PackageBlob{},
@@ -34,9 +34,9 @@ func TestCreatePackageMetadataResponse(t *testing.T) {
} }
result := createPackageMetadataResponse("https://gitea.dev/api/packages/alice/npm", []*packages_model.PackageDescriptor{ result := createPackageMetadataResponse("https://gitea.dev/api/packages/alice/npm", []*packages_model.PackageDescriptor{
descriptor("1.1.0", 1000, npm_module.Repository{}), descriptor("1.1.0", 1000, npm_module.Repository{}, "@scope/test/-/test-1.1.0.tgz"),
descriptor("2.0.0-rc.1", 1500, repository), descriptor("2.0.0-rc.1", 1500, repository, ""),
descriptor("1.0.0", 2000, repository), descriptor("1.0.0", 2000, repository, ""),
}) })
assert.Equal(t, map[string]time.Time{ assert.Equal(t, map[string]time.Time{
@@ -54,9 +54,13 @@ func TestCreatePackageMetadataResponse(t *testing.T) {
assert.Equal(t, []string{"gitea"}, result.Versions["1.0.0"].Keywords) assert.Equal(t, []string{"gitea"}, result.Versions["1.0.0"].Keywords)
assert.Equal(t, []npm_module.User{{Name: "alice"}}, result.Versions["1.0.0"].Maintainers) assert.Equal(t, []npm_module.User{{Name: "alice"}}, result.Versions["1.0.0"].Maintainers)
assert.Equal(t, assert.Equal(t,
"https://gitea.dev/api/packages/alice/npm/@scope/test/-/test-1.0.0.tgz", "https://gitea.dev/api/packages/alice/npm/%40scope%2Ftest/-/1.0.0/test-1.0.0.tgz",
result.Versions["1.0.0"].Dist.Tarball, result.Versions["1.0.0"].Dist.Tarball,
) )
assert.Equal(t,
"https://gitea.dev/api/packages/alice/npm/@scope/test/-/test-1.1.0.tgz",
result.Versions["1.1.0"].Dist.Tarball,
)
assert.Equal(t, repository, result.Versions["1.0.0"].Repository) assert.Equal(t, repository, result.Versions["1.0.0"].Repository)
withoutRepository, err := json.Marshal(result.Versions["1.1.0"]) withoutRepository, err := json.Marshal(result.Versions["1.1.0"])
+1 -1
View File
@@ -249,7 +249,7 @@ func DeleteLabel(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
if err := issues_model.DeleteLabel(ctx, ctx.Org.Organization.ID, ctx.PathParamInt64("id")); err != nil { if err := issues_model.DeleteOrgLabel(ctx, ctx.Org.Organization.ID, ctx.PathParamInt64("id")); err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return return
} }
+1 -1
View File
@@ -268,7 +268,7 @@ func DeleteLabel(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
if err := issues_model.DeleteLabel(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id")); err != nil { if err := issues_model.DeleteRepoLabel(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id")); err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return return
} }
+6
View File
@@ -717,7 +717,9 @@ func updateBasicProperties(ctx *context.APIContext, opts api.EditRepoOption) err
// Default branch only updated if changed and exist or the repository is empty // Default branch only updated if changed and exist or the repository is empty
updateRepoLicense := false updateRepoLicense := false
oldDefaultBranch := ""
if opts.DefaultBranch != nil && repo.DefaultBranch != *opts.DefaultBranch && (repo.IsEmpty || git.IsBranchExist(ctx, ctx.Repo.Repository, *opts.DefaultBranch)) { if opts.DefaultBranch != nil && repo.DefaultBranch != *opts.DefaultBranch && (repo.IsEmpty || git.IsBranchExist(ctx, ctx.Repo.Repository, *opts.DefaultBranch)) {
oldDefaultBranch = repo.DefaultBranch
repo.DefaultBranch = *opts.DefaultBranch repo.DefaultBranch = *opts.DefaultBranch
if !repo.IsEmpty { if !repo.IsEmpty {
if err := git.SetDefaultBranch(ctx, repo, repo.DefaultBranch); err != nil { if err := git.SetDefaultBranch(ctx, repo, repo.DefaultBranch); err != nil {
@@ -733,6 +735,10 @@ func updateBasicProperties(ctx *context.APIContext, opts api.EditRepoOption) err
return err return err
} }
if oldDefaultBranch != "" {
actions_service.SwitchSchedulesToDefaultBranch(ctx, repo, oldDefaultBranch)
}
if visibilityChanged { if visibilityChanged {
audit.Record(ctx, audit_model.RepositoryVisibility, repo, "visibility", repo.IsPrivate) audit.Record(ctx, audit_model.RepositoryVisibility, repo, "visibility", repo.IsPrivate)
} }
+1 -1
View File
@@ -186,7 +186,7 @@ func ListRuns(ctx *context.APIContext, ownerID, repoID int64, workflowID string)
if actor := ctx.FormString("actor"); actor != "" { if actor := ctx.FormString("actor"); actor != "" {
user, err := user_model.GetUserByName(ctx, actor) user, err := user_model.GetUserByName(ctx, actor)
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorAuto(err)
return return
} }
opts.TriggerUserID = user.ID opts.TriggerUserID = user.ID
+3 -2
View File
@@ -5,6 +5,7 @@ package private
import ( import (
"net/http" "net/http"
"time"
"gitea.dev/models/db" "gitea.dev/models/db"
"gitea.dev/modules/graceful" "gitea.dev/modules/graceful"
@@ -34,7 +35,7 @@ func FlushQueues(ctx *context.PrivateContext) {
// Save the hammer ctx here - as a new one is created each time you call this. // Save the hammer ctx here - as a new one is created each time you call this.
baseCtx := graceful.GetManager().HammerContext() baseCtx := graceful.GetManager().HammerContext()
go func() { go func() {
err := queue.GetManager().FlushAll(baseCtx, opts.Timeout) err := queue.GetManager().FlushAll(baseCtx, time.Duration(opts.Timeout))
if err != nil { if err != nil {
log.Error("Flushing request timed-out with error: %v", err) log.Error("Flushing request timed-out with error: %v", err)
} }
@@ -44,7 +45,7 @@ func FlushQueues(ctx *context.PrivateContext) {
}) })
return return
} }
err := queue.GetManager().FlushAll(ctx, opts.Timeout) err := queue.GetManager().FlushAll(ctx, time.Duration(opts.Timeout))
if err != nil { if err != nil {
ctx.PrivateUserErrorf(http.StatusRequestTimeout, "%v", err) ctx.PrivateUserErrorf(http.StatusRequestTimeout, "%v", err)
return return
+10 -4
View File
@@ -77,9 +77,9 @@ func prepareCommonAuthPageData(ctx *context.Context, opt CommonAuthOptions) {
// autoSignIn reads cookie and try to auto-login. // autoSignIn reads cookie and try to auto-login.
func autoSignIn(ctx *context.Context) (bool, error) { func autoSignIn(ctx *context.Context) (bool, error) {
isSucceed := false keepRememberCookie := false
defer func() { defer func() {
if !isSucceed { if !keepRememberCookie {
ctx.DeleteSiteCookie(setting.CookieRememberName) ctx.DeleteSiteCookie(setting.CookieRememberName)
} }
}() }()
@@ -112,14 +112,20 @@ func autoSignIn(ctx *context.Context) (bool, error) {
return false, fmt.Errorf("HasTwoFactorOrWebAuthn: %w", err) return false, fmt.Errorf("HasTwoFactorOrWebAuthn: %w", err)
} }
isSucceed = true
nt, token, err := auth_service.RegenerateAuthToken(ctx, t) nt, token, err := auth_service.RegenerateAuthToken(ctx, t)
if errors.Is(err, auth_service.ErrAuthTokenRotationConflict) {
keepRememberCookie = true // A late response must not clear the winner's cookie.
return false, nil
}
if errors.Is(err, auth_service.ErrAuthTokenExpired) {
return false, nil
}
if err != nil { if err != nil {
return false, err return false, err
} }
ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day) ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day)
keepRememberCookie = true
if err := regenerateSession(ctx, map[string]any{ if err := regenerateSession(ctx, map[string]any{
session.KeyUID: u.ID, session.KeyUID: u.ID,
-1
View File
@@ -31,7 +31,6 @@ func OIDCWellKnown(ctx *context.Context) {
"introspection_endpoint": oidcBaseUrl + "/login/oauth/introspect", "introspection_endpoint": oidcBaseUrl + "/login/oauth/introspect",
"response_types_supported": []string{ "response_types_supported": []string{
"code", "code",
"id_token",
}, },
"id_token_signing_alg_values_supported": []string{ "id_token_signing_alg_values_supported": []string{
oauth2_provider.DefaultSigningKey.SigningMethod().Alg(), oauth2_provider.DefaultSigningKey.SigningMethod().Alg(),
+1 -7
View File
@@ -69,13 +69,7 @@ func goGet(ctx *context.Context) {
goGetImport := context.ComposeGoGetImport(ctx, ownerName, trimmedRepoName) goGetImport := context.ComposeGoGetImport(ctx, ownerName, trimmedRepoName)
var cloneURL string goImportContent := fmt.Sprintf("%s git %s", goGetImport, context.ComposeGoGetCloneURL(ctx, ownerName, trimmedRepoName))
if setting.Repository.GoGetCloneURLProtocol == "ssh" {
cloneURL = repo_model.ComposeSSHCloneURL(ctx.Doer, ownerName, repoName)
} else {
cloneURL = repo_model.ComposeHTTPSCloneURL(ctx, ownerName, repoName)
}
goImportContent := fmt.Sprintf("%s git %s", goGetImport, cloneURL /*CloneLink*/)
goSourceContent := fmt.Sprintf("%s _ %s %s", goGetImport, prefix+"{/dir}" /*GoDocDirectory*/, prefix+"{/dir}/{file}#L{line}" /*GoDocFile*/) goSourceContent := fmt.Sprintf("%s _ %s %s", goGetImport, prefix+"{/dir}" /*GoDocDirectory*/, prefix+"{/dir}/{file}#L{line}" /*GoDocFile*/)
goGetCli := fmt.Sprintf("go get %s%s", insecure, goGetImport) goGetCli := fmt.Sprintf("go get %s%s", insecure, goGetImport)
+1 -1
View File
@@ -85,7 +85,7 @@ func UpdateLabel(ctx *context.Context) {
// DeleteLabel delete a label // DeleteLabel delete a label
func DeleteLabel(ctx *context.Context) { func DeleteLabel(ctx *context.Context) {
if err := issues_model.DeleteLabel(ctx, ctx.Org.Organization.ID, ctx.FormInt64("id")); err != nil { if err := issues_model.DeleteOrgLabel(ctx, ctx.Org.Organization.ID, ctx.FormInt64("id")); err != nil {
ctx.Flash.Error("DeleteLabel: " + err.Error()) ctx.Flash.Error("DeleteLabel: " + err.Error())
} else { } else {
ctx.Flash.Success(ctx.Tr("repo.issues.label_deletion_success")) ctx.Flash.Success(ctx.Tr("repo.issues.label_deletion_success"))
+1 -1
View File
@@ -853,7 +853,7 @@ func convertToViewModel(ctx context.Context, locale translation.Locale, cursors
} }
for _, cursor := range cursors { for _, cursor := range cursors {
if !cursor.Expanded { if !cursor.Expanded || cursor.Step < 0 || cursor.Step >= len(steps) {
continue continue
} }
+7
View File
@@ -88,6 +88,13 @@ func TestConvertToViewModel(t *testing.T) {
viewJobSteps, _, err := convertToViewModel(t.Context(), translation.MockLocale{}, nil, task) viewJobSteps, _, err := convertToViewModel(t.Context(), translation.MockLocale{}, nil, task)
require.NoError(t, err) require.NoError(t, err)
t.Run("out of range cursor step", func(t *testing.T) {
cursors := []LogCursor{{Step: -1, Expanded: true}, {Step: 3, Expanded: true}}
_, logs, err := convertToViewModel(t.Context(), translation.MockLocale{}, cursors, task)
require.NoError(t, err)
assert.Empty(t, logs)
})
expectedViewJobs := []*ViewJobStep{ expectedViewJobs := []*ViewJobStep{
{ {
Summary: "Set up job", Summary: "Set up job",
+1 -1
View File
@@ -152,7 +152,7 @@ func UpdateLabel(ctx *context.Context) {
// DeleteLabel delete a label // DeleteLabel delete a label
func DeleteLabel(ctx *context.Context) { func DeleteLabel(ctx *context.Context) {
if err := issues_model.DeleteLabel(ctx, ctx.Repo.Repository.ID, ctx.FormInt64("id")); err != nil { if err := issues_model.DeleteRepoLabel(ctx, ctx.Repo.Repository.ID, ctx.FormInt64("id")); err != nil {
ctx.Flash.Error("DeleteLabel: " + err.Error()) ctx.Flash.Error("DeleteLabel: " + err.Error())
} else { } else {
ctx.Flash.Success(ctx.Tr("repo.issues.label_deletion_success")) ctx.Flash.Success(ctx.Tr("repo.issues.label_deletion_success"))
+1 -1
View File
@@ -49,7 +49,7 @@ func ActionWatch(ctx *context.Context) {
// ActionWatchOptions watches the repository with a custom selection of events // ActionWatchOptions watches the repository with a custom selection of events
func ActionWatchOptions(ctx *context.Context) { func ActionWatchOptions(ctx *context.Context) {
opts := repo_model.WatchOptions{ // clearing every event is allowed, it leaves the participating state opts := repo_model.WatchOptions{ // clearing every event is allowed, it leaves the participating state
Mode: repo_model.WatchModeNormal, Mode: repo_model.WatchModeCustom,
WatchPullRequests: ctx.FormBool("pull_requests"), WatchPullRequests: ctx.FormBool("pull_requests"),
WatchIssues: ctx.FormBool("issues"), WatchIssues: ctx.FormBool("issues"),
WatchReleases: ctx.FormBool("releases"), WatchReleases: ctx.FormBool("releases"),
+12 -1
View File
@@ -5,11 +5,14 @@ package security
import ( import (
"errors" "errors"
"fmt"
"net/http" "net/http"
"time"
audit_model "gitea.dev/models/audit" audit_model "gitea.dev/models/audit"
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
"gitea.dev/modules/auth/openid" "gitea.dev/modules/auth/openid"
"gitea.dev/modules/base"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
"gitea.dev/modules/util" "gitea.dev/modules/util"
@@ -19,6 +22,8 @@ import (
"gitea.dev/services/forms" "gitea.dev/services/forms"
) )
const openIDAssociationCodeMinutes = 10
// OpenIDPost response for change user's openid // OpenIDPost response for change user's openid
func OpenIDPost(ctx *context.Context) { func OpenIDPost(ctx *context.Context) {
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageCredentials) { if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageCredentials) {
@@ -70,7 +75,8 @@ func OpenIDPost(ctx *context.Context) {
} }
} }
redirectTo := setting.AppURL + "user/settings/security" state := base.CreateTimeLimitCode(fmt.Sprintf("openid_association:%d", ctx.Doer.ID), openIDAssociationCodeMinutes, time.Now(), nil)
redirectTo := setting.AppURL + "user/settings/security?state=" + state
url, err := openid.RedirectURL(id, redirectTo, setting.AppURL) url, err := openid.RedirectURL(id, redirectTo, setting.AppURL)
if err != nil { if err != nil {
loadSecurityData(ctx) loadSecurityData(ctx)
@@ -82,6 +88,11 @@ func OpenIDPost(ctx *context.Context) {
} }
func settingsOpenIDVerify(ctx *context.Context) { func settingsOpenIDVerify(ctx *context.Context) {
if !base.VerifyTimeLimitCode(time.Now(), fmt.Sprintf("openid_association:%d", ctx.Doer.ID), openIDAssociationCodeMinutes, ctx.FormString("state")) {
ctx.HTTPError(http.StatusForbidden)
return
}
log.Trace("Incoming call to: " + ctx.Req.URL.String()) log.Trace("Incoming call to: " + ctx.Req.URL.String())
fullURL := setting.AppURL + ctx.Req.URL.String()[1:] fullURL := setting.AppURL + ctx.Req.URL.String()[1:]
@@ -4,15 +4,103 @@
package security package security
import ( import (
"fmt"
"net/http" "net/http"
"net/http/httptest"
"net/url"
"testing" "testing"
"time"
"gitea.dev/models/unittest" "gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/base"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"gitea.dev/modules/web"
"gitea.dev/services/contexttest" "gitea.dev/services/contexttest"
"gitea.dev/services/forms"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestOpenIDAssociation(t *testing.T) {
unittest.PrepareTestEnv(t)
defer test.MockVariableValue(&setting.AppURL, "https://gitea.example/")()
defer test.MockVariableValue(&setting.AppSubURL, "")()
defer test.MockVariableValue(&setting.Security.AllowedHostList, "127.0.0.1:*")()
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
assert.Equal(t, "check_authentication", r.FormValue("openid.mode"))
w.Header().Set("Content-Type", "text/plain")
fmt.Fprint(w, "ns:http://specs.openid.net/auth/2.0\nis_valid:true\n")
return
}
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<html><head><link rel="openid2.provider" href="http://%s/provider"></head></html>`, r.Host)
}))
defer provider.Close()
identity := provider.URL + "/identity"
ctx, resp := contexttest.MockContext(t, "POST /user/settings/security/openid")
contexttest.LoadUser(t, ctx, 2)
web.SetForm(ctx, &forms.AddOpenIDForm{Openid: identity})
OpenIDPost(ctx)
require.Equal(t, http.StatusSeeOther, resp.Code)
redirect, err := url.Parse(resp.Header().Get("Location"))
require.NoError(t, err)
callback, err := url.Parse(redirect.Query().Get("openid.return_to"))
require.NoError(t, err)
state := callback.Query().Get("state")
require.NotEmpty(t, state)
params := url.Values{
"openid.ns": {"http://specs.openid.net/auth/2.0"},
"openid.mode": {"id_res"},
"openid.op_endpoint": {provider.URL + "/provider"},
"openid.claimed_id": {identity},
"openid.identity": {identity},
"openid.return_to": {callback.String()},
"openid.response_nonce": {time.Now().UTC().Format("2006-01-02T15:04:05Z") + "association"},
"openid.assoc_handle": {"test-association"},
"openid.signed": {"op_endpoint,claimed_id,identity,return_to,response_nonce,assoc_handle"},
"openid.sig": {"test-signature"},
}
for _, tc := range []struct {
name string
uid int64
state string
}{
{"unsolicited", 2, ""},
{"wrong state", 2, "wrong"},
{"other user", 1, state},
{"expired", 2, base.CreateTimeLimitCode("openid_association:2", openIDAssociationCodeMinutes, time.Now().Add(-time.Hour), nil)},
{"other purpose", 2, base.CreateTimeLimitCode("reset_password:2", openIDAssociationCodeMinutes, time.Now(), nil)},
} {
t.Run(tc.name, func(t *testing.T) {
params.Set("state", tc.state)
ctx, resp := contexttest.MockContext(t, "GET /user/settings/security?"+params.Encode())
contexttest.LoadUser(t, ctx, tc.uid)
Security(ctx)
assert.Equal(t, http.StatusForbidden, resp.Code)
unittest.AssertNotExistsBean(t, &user_model.UserOpenID{URI: identity})
})
}
params.Set("state", state)
ctx, resp = contexttest.MockContext(t, "GET /user/settings/security?"+params.Encode())
contexttest.LoadUser(t, ctx, 2)
Security(ctx)
require.Equal(t, http.StatusSeeOther, resp.Code)
unittest.AssertExistsAndLoadBean(t, &user_model.UserOpenID{UID: 2, URI: identity})
ctx, resp = contexttest.MockContext(t, "GET /user/settings/security?"+params.Encode())
contexttest.LoadUser(t, ctx, 2)
Security(ctx)
assert.Equal(t, http.StatusOK, resp.Code)
assert.Equal(t, "Nonce already used", ctx.Flash.ErrorMsg)
}
func TestDeleteOpenIDReturnsNotFoundForOtherUsersAddress(t *testing.T) { func TestDeleteOpenIDReturnsNotFoundForOtherUsersAddress(t *testing.T) {
unittest.PrepareTestEnv(t) unittest.PrepareTestEnv(t)
ctx, _ := contexttest.MockContext(t, "POST /user/settings/security?id=1") ctx, _ := contexttest.MockContext(t, "POST /user/settings/security?id=1")
-1
View File
@@ -197,7 +197,6 @@ func verifyAuthWithOptionsWeb(options *common.VerifyOptions) func(ctx *context.C
ctx.HTTPError(http.StatusUnauthorized, ctx.Locale.TrString("auth.must_change_password")) ctx.HTTPError(http.StatusUnauthorized, ctx.Locale.TrString("auth.must_change_password"))
return return
} }
middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI())
ctx.Redirect(setting.AppSubURL + "/user/settings/change_password") ctx.Redirect(setting.AppSubURL + "/user/settings/change_password")
return return
} }
+7
View File
@@ -11,6 +11,7 @@ import (
actions_model "gitea.dev/models/actions" actions_model "gitea.dev/models/actions"
"gitea.dev/models/db" "gitea.dev/models/db"
"gitea.dev/modules/actions/jobparser" "gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/log"
) )
// CancelRun cancels a run's cancellable jobs and returns the run's post-cancellation state. // CancelRun cancels a run's cancellable jobs and returns the run's post-cancellation state.
@@ -52,6 +53,12 @@ func cancelRun(ctx context.Context, run *actions_model.ActionRun, jobs []*action
if len(updatedJobs) > 0 || reloaded.Status != run.Status { if len(updatedJobs) > 0 || reloaded.Status != run.Status {
NotifyWorkflowRunStatusUpdate(ctx, reloaded) NotifyWorkflowRunStatusUpdate(ctx, reloaded)
} }
if len(updatedJobs) == 0 && reloaded.Status != run.Status {
// the run's status was updated, so emit it for the emitter to check
if err := EmitJobsIfReadyByRun(run.ID); err != nil {
log.Error("Check jobs of run %d: %v", run.ID, err)
}
}
return reloaded, nil return reloaded, nil
} }
+76
View File
@@ -0,0 +1,76 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"testing"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
"gitea.dev/models/unittest"
"gitea.dev/modules/test"
"gitea.dev/modules/timeutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestForceCancelRun_SettledRunWakesConcurrencyWaiters(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
// a run left cancelling although its caller and child are all cancelled already
run := &actions_model.ActionRun{
Title: "settled-run",
RepoID: 4,
Index: 9821,
OwnerID: 1,
WorkflowID: "test.yaml",
TriggerUserID: 1,
Ref: "refs/heads/master",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
Event: "push",
TriggerEvent: "push",
EventPayload: "{}",
Status: actions_model.StatusCancelling,
}
require.NoError(t, db.Insert(ctx, run))
attempt := &actions_model.ActionRunAttempt{RepoID: run.RepoID, RunID: run.ID, Attempt: 1, TriggerUserID: 1, Status: actions_model.StatusCancelling}
require.NoError(t, db.Insert(ctx, attempt))
run.LatestAttemptID = attempt.ID
require.NoError(t, actions_model.UpdateRun(ctx, run, "latest_attempt_id"))
newJob := func(name string, parentID int64, isCaller bool) *actions_model.ActionRunJob {
job := &actions_model.ActionRunJob{
RunID: run.ID,
RunAttemptID: attempt.ID,
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
Name: name,
JobID: name,
Attempt: 1,
Status: actions_model.StatusCancelled,
Stopped: timeutil.TimeStampNow(),
ParentJobID: parentID,
IsReusableCaller: isCaller,
IsExpanded: isCaller,
}
require.NoError(t, db.Insert(ctx, job))
return job
}
caller := newJob("caller", 0, true)
child := newJob("child", caller.ID, false)
var emitted []int64
defer test.MockVariableValue(&EmitJobsIfReadyByRun, func(runID int64) error {
emitted = append(emitted, runID)
return nil
})()
got, err := ForceCancelRun(ctx, run, []*actions_model.ActionRunJob{caller, child})
require.NoError(t, err)
assert.Equal(t, actions_model.StatusCancelled, got.Status)
assert.Equal(t, []int64{run.ID}, emitted)
}
+18
View File
@@ -12,7 +12,9 @@ import (
actions_model "gitea.dev/models/actions" actions_model "gitea.dev/models/actions"
"gitea.dev/models/db" "gitea.dev/models/db"
repo_model "gitea.dev/models/repo" repo_model "gitea.dev/models/repo"
unit_model "gitea.dev/models/unit"
"gitea.dev/modules/actions" "gitea.dev/modules/actions"
"gitea.dev/modules/git"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
"gitea.dev/modules/timeutil" "gitea.dev/modules/timeutil"
@@ -62,6 +64,22 @@ func CleanRepoScheduleTasks(ctx context.Context, repo *repo_model.Repository) er
return err return err
} }
// SwitchSchedulesToDefaultBranch cancels the running schedule runs of the old default branch
// and replaces the schedules with the ones on the current default branch
func SwitchSchedulesToDefaultBranch(ctx context.Context, repo *repo_model.Repository, oldDefaultBranch string) {
if err := actions_model.DeleteScheduleTaskByRepo(ctx, repo.ID); err != nil {
log.Error("DeleteScheduleTaskByRepo: %v", err)
}
if err := CancelPreviousJobs(ctx, repo.ID, git.RefNameFromBranch(oldDefaultBranch).String(), "", webhook_module.HookEventSchedule); err != nil {
log.Error("CancelPreviousJobs: %v", err)
}
if repo.UnitEnabled(ctx, unit_model.TypeActions) {
if err := DetectAndHandleSchedules(ctx, repo); err != nil {
log.Error("DetectAndHandleSchedules: %v", err)
}
}
}
func shouldBlockJobByConcurrency(ctx context.Context, job *actions_model.ActionRunJob) (bool, error) { func shouldBlockJobByConcurrency(ctx context.Context, job *actions_model.ActionRunJob) (bool, error) {
if job.RawConcurrency != "" && !job.IsConcurrencyEvaluated { if job.RawConcurrency != "" && !job.IsConcurrencyEvaluated {
// when the job depends on other jobs, we cannot evaluate its concurrency, so it should be blocked and will be evaluated again when its dependencies are done // when the job depends on other jobs, we cannot evaluate its concurrency, so it should be blocked and will be evaluated again when its dependencies are done
+3 -2
View File
@@ -577,8 +577,9 @@ func handleSchedules(
log.Error("CountSchedules: %v", err) log.Error("CountSchedules: %v", err)
return err return err
} else if count > 0 { } else if count > 0 {
if err := CleanRepoScheduleTasks(ctx, input.Repo); err != nil { // replacing the schedules leaves their in-progress runs alone, as GitHub does
log.Error("CleanRepoScheduleTasks: %v", err) if err := actions_model.DeleteScheduleTaskByRepo(ctx, input.Repo.ID); err != nil {
log.Error("DeleteScheduleTaskByRepo: %v", err)
} }
} }
+4 -1
View File
@@ -188,7 +188,10 @@ func insertRunJob(ctx context.Context, run *actions_model.ActionRun, runAttempt
if err := workflowJob.SetJob(id, job.EraseNeeds()); err != nil { if err := workflowJob.SetJob(id, job.EraseNeeds()); err != nil {
return nil, nil, false, err return nil, nil, false, err
} }
payload, _ := workflowJob.Marshal() payload, err := workflowJob.Marshal()
if err != nil {
return nil, nil, false, fmt.Errorf("insertRunJob: marshal job %q: %w", id, err)
}
isReusableWorkflowCaller := job.Uses != "" isReusableWorkflowCaller := job.Uses != ""
status := util.Iif(runAttempt.Status == actions_model.StatusBlocked || run.NeedApproval, actions_model.StatusBlocked, actions_model.StatusWaiting) status := util.Iif(runAttempt.Status == actions_model.StatusBlocked || run.NeedApproval, actions_model.StatusBlocked, actions_model.StatusWaiting)
+19 -4
View File
@@ -24,9 +24,10 @@ import (
// If a device uses the token to login into the instance, a fresh token gets generated which has the same id but a new hash. // If a device uses the token to login into the instance, a fresh token gets generated which has the same id but a new hash.
var ( var (
ErrAuthTokenInvalidFormat = util.NewInvalidArgumentErrorf("auth token has an invalid format") ErrAuthTokenInvalidFormat = util.NewInvalidArgumentErrorf("auth token has an invalid format")
ErrAuthTokenExpired = util.NewInvalidArgumentErrorf("auth token has expired") ErrAuthTokenExpired = util.NewInvalidArgumentErrorf("auth token has expired")
ErrAuthTokenInvalidHash = util.NewInvalidArgumentErrorf("auth token is invalid") ErrAuthTokenInvalidHash = util.NewInvalidArgumentErrorf("auth token is invalid")
ErrAuthTokenRotationConflict = util.NewInvalidArgumentErrorf("auth token was rotated by another request")
) )
func CheckAuthToken(ctx context.Context, value string) (*auth_model.AuthToken, error) { func CheckAuthToken(ctx context.Context, value string) (*auth_model.AuthToken, error) {
@@ -76,9 +77,23 @@ func RegenerateAuthToken(ctx context.Context, t *auth_model.AuthToken) (*auth_mo
ExpiresUnix: timeutil.TimeStampNow().AddDuration(time.Duration(setting.LogInRememberDays*24) * time.Hour), ExpiresUnix: timeutil.TimeStampNow().AddDuration(time.Duration(setting.LogInRememberDays*24) * time.Hour),
} }
if err := auth_model.UpdateAuthTokenByID(ctx, newToken); err != nil { updated, err := auth_model.UpdateAuthTokenByID(ctx, newToken, t.TokenHash)
if err != nil {
return nil, "", err return nil, "", err
} }
if !updated {
current, err := auth_model.GetAuthTokenByID(ctx, t.ID)
if errors.Is(err, util.ErrNotExist) {
return nil, "", ErrAuthTokenExpired
}
if err != nil {
return nil, "", err
}
if current.ExpiresUnix < timeutil.TimeStampNow() {
return nil, "", ErrAuthTokenExpired
}
return nil, "", ErrAuthTokenRotationConflict
}
return newToken, token, nil return newToken, token, nil
} }
+21
View File
@@ -13,6 +13,7 @@ import (
"gitea.dev/modules/util" "gitea.dev/modules/util"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestCheckAuthToken(t *testing.T) { func TestCheckAuthToken(t *testing.T) {
@@ -105,5 +106,25 @@ func TestRegenerateAuthToken(t *testing.T) {
assert.NotEqual(t, token, token2) assert.NotEqual(t, token, token2)
assert.NotEqual(t, at.ExpiresUnix, at2.ExpiresUnix) assert.NotEqual(t, at.ExpiresUnix, at2.ExpiresUnix)
// Only one request may rotate the same validated cookie.
competing, competingToken, err := RegenerateAuthToken(t.Context(), at)
require.ErrorIs(t, err, ErrAuthTokenRotationConflict)
require.Nil(t, competing)
require.Empty(t, competingToken)
current, err := CheckAuthToken(t.Context(), at2.ID+":"+token2)
require.NoError(t, err)
require.Equal(t, at2.TokenHash, current.TokenHash)
assert.NoError(t, auth_model.DeleteAuthTokenByID(t.Context(), at.ID)) assert.NoError(t, auth_model.DeleteAuthTokenByID(t.Context(), at.ID))
} }
func TestRegenerateRevokedAuthToken(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
at, _, err := CreateAuthTokenForUserID(t.Context(), 2)
require.NoError(t, err)
require.NoError(t, auth_model.DeleteAuthTokenByID(t.Context(), at.ID))
rotated, token, err := RegenerateAuthToken(t.Context(), at)
require.ErrorIs(t, err, ErrAuthTokenExpired)
require.Nil(t, rotated)
require.Empty(t, token)
}
+10 -7
View File
@@ -389,6 +389,15 @@ func ComposeGoGetImport(ctx context.Context, owner, repo string) string {
return path.Join(curAppURL.Host, setting.AppSubURL, url.PathEscape(owner), url.PathEscape(repo)) return path.Join(curAppURL.Host, setting.AppSubURL, url.PathEscape(owner), url.PathEscape(repo))
} }
// ComposeGoGetCloneURL returns the clone URL for the go-import meta content.
func ComposeGoGetCloneURL(ctx *Context, owner, repo string) string {
useSSH := setting.Repository.GoGetCloneURLProtocol == "ssh" || (setting.Repository.DisableHTTPGit && !setting.SSH.Disabled)
if useSSH {
return repo_model.ComposeSSHCloneURI(ctx.Doer, owner, repo)
}
return repo_model.ComposeHTTPSCloneURL(ctx, owner, repo)
}
// EarlyResponseForGoGetMeta responses appropriate go-get meta with status 200 // EarlyResponseForGoGetMeta responses appropriate go-get meta with status 200
// if user does not have actual access to the requested repository, // if user does not have actual access to the requested repository,
// or the owner or repository does not exist at all. // or the owner or repository does not exist at all.
@@ -402,13 +411,7 @@ func EarlyResponseForGoGetMeta(ctx *Context) {
return return
} }
var cloneURL string goImportContent := fmt.Sprintf("%s git %s", ComposeGoGetImport(ctx, username, reponame), ComposeGoGetCloneURL(ctx, username, reponame))
if setting.Repository.GoGetCloneURLProtocol == "ssh" {
cloneURL = repo_model.ComposeSSHCloneURL(ctx.Doer, username, reponame)
} else {
cloneURL = repo_model.ComposeHTTPSCloneURL(ctx, username, reponame)
}
goImportContent := fmt.Sprintf("%s git %s", ComposeGoGetImport(ctx, username, reponame), cloneURL)
htmlMeta := fmt.Sprintf(`<meta name="go-import" content="%s">`, html.EscapeString(goImportContent)) htmlMeta := fmt.Sprintf(`<meta name="go-import" content="%s">`, html.EscapeString(goImportContent))
ctx.PlainText(http.StatusOK, htmlMeta) ctx.PlainText(http.StatusOK, htmlMeta)
} }
+1
View File
@@ -119,6 +119,7 @@ func checkStorage(opts *checkStorageOptions) func(ctx context.Context, logger lo
&commonStorageCheckOptions{ &commonStorageCheckOptions{
storer: storage.LFS, storer: storage.LFS,
isOrphaned: func(path string, obj storage.Object, stat fs.FileInfo) (bool, error) { isOrphaned: func(path string, obj storage.Object, stat fs.FileInfo) (bool, error) {
// HINT: LFS-INTERNAL-PATH-PATTERN
// The oid of an LFS stored object is the name but with all the path.Separators removed // The oid of an LFS stored object is the name but with all the path.Separators removed
oid := strings.ReplaceAll(strings.ReplaceAll(path, "\\", ""), "/", "") oid := strings.ReplaceAll(strings.ReplaceAll(path, "\\", ""), "/", "")
exists, err := git.ExistsLFSObject(ctx, oid) exists, err := git.ExistsLFSObject(ctx, oid)
+1 -1
View File
@@ -65,6 +65,6 @@ type AdminEditUserForm struct {
// AdminDashboardForm form for admin dashboard operations // AdminDashboardForm form for admin dashboard operations
type AdminDashboardForm struct { type AdminDashboardForm struct {
middleware.FormDefaultValidator middleware.FormDefaultValidator
Op string `binding:"required"` Op string `binding:"Required"`
From string From string
} }
-6
View File
@@ -571,12 +571,6 @@ type AddTimeManuallyForm struct {
Minutes int `binding:"Range(0,1000)"` Minutes int `binding:"Range(0,1000)"`
} }
// SaveTopicForm form for save topics for repository
type SaveTopicForm struct {
middleware.FormDefaultValidator
Topics []string `binding:"topics;Required;"`
}
// AddDeployTokenForm form for adding a deploy token to a repository // AddDeployTokenForm form for adding a deploy token to a repository
type AddDeployTokenForm struct { type AddDeployTokenForm struct {
middleware.FormDefaultValidator middleware.FormDefaultValidator
Loaded 100 of 141 files, more files were not shown because too many files have changed in this diff. Show more