Compare commits

..
28 Commits
Author SHA1 Message Date
bircniandGitHub ba4db8a2d9 fix(actions): let a rerun of selected jobs read the previous attempt's artifacts (#38857) (#38901)
Backport #38857

Fixes #38773

## Background

Artifacts became attempt-scoped in #37119, and the runner-facing
artifact APIs filter strictly by the attempt of the running job. "Re-run
failed jobs" creates a new attempt whose passed-through jobs never
upload their artifacts again, so a re-run job that downloads one of them
fails with "artifact not found".

## Fix

The read paths (v3 and v4 list and download) now resolve artifacts
across the running job's attempt plus the attempts it inherits from, and
an inherited artifact is shadowed by a same-named one from a newer
attempt.

## Note

GitHub's documentation does not document these behaviors. The
conclusions below are based on manual testing, so consistency with
GitHub cannot be guaranteed.

- In a "partial re-run", a job can download artifacts uploaded by an
earlier attempt, every attempt keeps its own copy of a name, and a
lookup by name resolves to the newest one.
- A full "Re-run all jobs" never downloads artifacts from earlier
attempts.
2026-08-13 13:31:42 +00:00
wxiaoguangandGitHub 0acbcc58a7 fix: update collaborator access mode and httpsign (#38894, #38862) (#38895)
backport #38894, partially #38862
2026-08-13 09:59:07 +00:00
88b56d408d refactor: external render (#38885) (#38898)
Backport #38885 by @wxiaoguang

make the "command variable replacement" more accurate and
OS-independent, add a test for it.

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-08-12 23:17:39 -07:00
1c92062c69 fix(actions): resolve pull_request_target reusable workflows at the base commit (#38886) (#38897)
Backport #38886

For a `pull_request_target` (PRT) run, Gitea loads the top-level
workflow from the trusted base branch, but any local reusable workflow
it calls (`uses: ./...`) was read from the PR **head** commit, which the
fork author controls.

**Record the source commit where the content is read.**
`DetectedWorkflow` now carries a `SourceCommitSHA` filled in next to
`Content`, so the PRT detection pass at the base commit records the base
SHA automatically.

**Defense in depth.** `loadReusableWorkflowSource` pins the PR base
commit for a PRT run's local `uses: ./...` rather than trusting the
stored SHA. This also covers runs recorded before this change, whose
rows still hold the head SHA and would otherwise resolve from the fork
on rerun.

Existing run rows are not migrated.

Co-authored-by: bircni <bircni@icloud.com>
2026-08-12 21:12:39 +02:00
51938de973 fix(lfs): accept successful transfer responses (#38866) (#38875)
Backport #38866 by @Pachinko0

Accept all 2xx LFS transfer responses so uploads returning 201 Created
are not handled as errors and decoded as empty error bodies.

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

----

Co-authored-by: waterWang <waterWang@users.noreply.github.com>

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Pachink0 <55147665+Pachinko0@users.noreply.github.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-08-11 09:43:05 -07:00
wxiaoguangandGitHub 21fda8f5be refactor: markup render (#38864) (#38869)
backport #38864

1. add missing CSP header to api & web render endpoints.
2. make jupyter render skip post-processors, nothing to process
2026-08-11 18:00:47 +02:00
9ab9c18919 enhance: add missing npm package metadata properties (#38826) (#38831)
Backport #38826 by @silverwind

The npm packument left `time`, `keywords` and `maintainers` empty
although the data was available. `created` and `modified` are derived
from the versions currently served, as there is no package-level
timestamp to read them from.

Co-authored-by: silverwind <me@silverwind.io>
2026-08-08 16:50:10 +00:00
e2a0a87ae0 fix(packages): ignore nested Package.swift (#38788) (#38836)
Backport #38788 by @terriblegoodday

Nested `Package.swift` files overwrote the real package manifest. The
parser matched on the base name and kept the last entry in ZIP order.

`apple/swift-collections` ships `Benchmarks/Package.swift` and
`Utils/Debugger/FormatterFixtures/Package.swift`. The latter sorts after
the root `Package.swift`, so the registry stored a fixture manifest with
the wrong `swift-tools-version`, causing a toolchain mismatch and a
failed build. GRDB, swift-markdown, swift-syntax, sentry-cocoa and
SDWebImage share this layout.

The parser now keeps only manifests from the shallowest directory
holding one. That covers both a package at the archive root and the
single top level directory `swift package archive-source` produces. At
equal depth the first directory by name wins, so an archive always
yields the same metadata.

A nested manifest above the size limit no longer rejects the upload.

Assisted by Claude Opus 5 and Claude Fable.

Co-authored-by: Eduard Dzhumagaliev <ed_dzhumagaliev@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-08-08 08:43:21 -07:00
silverwindandGitHub 92044649a0 fix(auth): set WebAuthn user verification per request (#38810)
Backport #38805

Registration omitted `userVerification`, so Chromium raised the
credential to credProtect level 3 and the authenticator then hid it from
the second-factor login, which asked for `discouraged`. Registration and
each login now set their own value, with `preferred` on the second
factor so credentials already registered at level 3 keep working without
re-enrollment.

Also add relevant e2e test coverage for webauthn, one test is chromium
only because Firefox lacks the APIs needed.

Fixes https://github.com/go-gitea/gitea/issues/33531
Fixes https://github.com/go-gitea/gitea/issues/36019
Fixes https://github.com/go-gitea/gitea/issues/38139
2026-08-08 10:11:04 +00:00
silverwindandGitHub 94011d2850 fix(ui): change underlines to default browser style (#38819) (#38823)
Remove all underline style customization on links, letting the browser defaults apply
2026-08-08 01:49:41 +00:00
wxiaoguangandGitHub fe252be0ae fix(storage): fix Azure Blob dump failing with file does not exist (#38814) (#38828)
backport #38814
2026-08-07 23:08:46 +00:00
cca0c65a6c fix: drop newline-bearing member names in arch ParsePackage (#38102) (#38830)
Backport #38102 by @metsw24-max

The arch parser keeps tar member names verbatim. The index writer joins
those values one per line into the pacman database. So a member name
with a newline adds lines to that package's own `files` entry, which
libalpm reads as further fields.

The scope is one package record. An uploader cannot forge entries for
another package, and can set the same fields in `.PKGINFO` anyway. This
is input validation, not a privilege boundary.

`ParsePackage` now drops names that contain CR or LF. `joinFields` drops
such values again when writing the index, which also covers packages
that are already stored. Real packages never carry newlines in file
paths, so well-formed uploads are unaffected.

Co-authored-by: metsw24-max <metsw24@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-08-08 00:46:08 +02:00
6387c8ba6e fix(migration): migration deletion returned json redirection (#38796) (#38825)
Backport #38796 by @lunny

Fix #38596

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-08-07 14:00:02 +00:00
silverwindandGitHub 6eab271921 fix(deps): update dependency mermaid to v11.16.1 [security] (#38816)
Backport #38813
2026-08-07 09:53:50 +02:00
Zettat123andGitHub eab225f095 fix(actions): allow cancelling runs without running jobs (#35842) (#38812) 2026-08-07 06:54:08 +02:00
silverwindandGitHub 00a637295e fix(actions): evaluate each ${{ }} part on its own (#38754) (#38797)
Backport of https://github.com/go-gitea/gitea/pull/38754

Every `${{ }}` part was spliced as raw text into a synthesized
`format('...', <raw>)` call and re-parsed, so unbalanced parentheses
restructured the whole expression:

```yaml
run-name: ${{ 1) && (2 }}          # panicked, aborting workflow parsing for the push
if: ${{ 1 }} ${{ 0) && (0 }}       # silently skipped the job
runs-on: ${{ nosuchcontext.x }}    # silently queued the job against the label ""
```

One scanner shaped like GitHub's template reader now splits every value
and each part is evaluated on its own, so nothing builds an expression
out of text. A part that fails is an error instead of an empty string.

`expressionCallsFunction` is self-contained here, since this branch has
no `expressionsMatch` to build it on. That makes
`github.com/rhysd/actionlint` a direct dependency, which it already is
on `main`.
2026-08-06 23:28:48 +00:00
wxiaoguangandGitHub 4e64b3a65d fix: render highlight language (#38793) (#38795)
backport #38793
2026-08-06 10:34:28 +00:00
b71adfe1ad fix(actions): write an action task report in one transaction (#38792) (#38794)
Backport #38792 by @silverwind

`UpdateTaskByState` wrote the task, its job and its steps in separate
statements. An interruption in between left the task finished with a
running job, so the run stayed in progress, and the "state is final"
early return made every retry, cancel and cleanup a no-op.

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

Co-authored-by: silverwind <me@silverwind.io>
2026-08-06 06:45:42 +00:00
e5c6669751 fix: markup link (#38764) (#38765)
Backport #38764

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-08-03 15:04:52 +00:00
e0e10052e0 fix: set a minio part size when the content size is unknown (#38753) (#38755)
Backport #38753 by @Zettat123

## Background

`MinioStorage.Save` is called with `size = -1` on several paths,
including Actions logs, Actions artifacts, repository archives, avatars
and attachments. With an unknown size (-1) minio-go assumes a 5TiB
object and allocates a single part-sized buffer of 528MiB per upload,
regardless of the real payload size, which can exhaust the memory of
small instances.

Measured against a local S3 stub, five sequential uploads of a 4KiB
payload grew RSS by 1041MiB before the fix and by 34MiB after it.

## Fix

Pass an explicit 16MiB part size in that case, the same value minio-go
uses as minimum part size
(https://github.com/minio/minio-go/blob/v7.2.1/constants.go#L28).

Uploads with a known size are left untouched, since minio-go already
derives a part size proportional to the real object size.

## Note:

One behaviour change: with an unknown size the object is now limited to
16MiB * 10000 parts = 156.25GiB
(https://github.com/minio/minio-go/blob/v7.2.1/api-put-object-common.go#L112-L116).
`putObjectMultipartStreamNoLength` completes the upload once it runs out
of parts without checking that the reader was drained, so a stream past
that limit is silently truncated rather than rejected. The previous
limit was 5TiB. No payload Gitea uploads comes close to either.

Parts are also uploaded serially, so a smaller part size means
proportionally more round trips for large unknown-size uploads.

Co-authored-by: Zettat123 <zettat123@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-08-03 00:17:13 +00:00
c461575af3 fix: bad path escape in subpath archive download (#38749) (#38750)
Backport #38749

Co-authored-by: TheFox0x7 <thefox0x7@gmail.com>
2026-08-02 19:30:05 +00:00
wxiaoguangandGitHub 7fb9602961 fix: remove the pull merge box from UI when the refreshed page doesn't contain it (#38742) (#38744)
backport #38742
2026-08-02 16:55:17 +02:00
wxiaoguangandGitHub a8e80ebc23 fix(lfs): failed upload deletes a concurrent upload's meta object (#38693) (#38722)
backport #38693
2026-07-31 13:41:26 +00:00
8ab5d31cf3 fix(markdown): fix double strikethough on code (#38707) (#38729)
Backport #38707 by @silverwind

`.markup del code { text-decoration: inherit }` makes inline code inside
`<del>` paint its own line-through in addition to the one already
propagating from the parent. Since `code` is `font-size: 85%`, the two
land at different heights and render as a doubled strikethrough.

The rule was inherited from primer-markdown, where it was added in
https://github.com/primer/css/commit/762b8b8264aa4c4beed0a7f842f90c142eb2b310
("so that `<del>` or `<a>` has the same effects on `<code>` tags") at a
time when inline `code` was `display: inline-block`, a box that text
decorations do not propagate into. That `display: inline-block` was
removed 11 days later in
https://github.com/primer/css/commit/f1131d5ab618ac41d4097823b511ca0b373b2ee2,
which made the rule redundant, but it survived the squashed import into
primer/css and every copy downstream of it.

No other popular markdown stylesheet carries an equivalent rule, GitHub
still ships it and shows the same doubled line.

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

Co-authored-by: silverwind <me@silverwind.io>
2026-07-31 06:12:20 -07:00
b2af380d66 fix: correct full url when using sub-path (#38712) (#38716)
Backport #38712

fix #38708

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-07-30 19:34:26 +00:00
wxiaoguangandGitHub d2603a8b4a fix: avoid markup render panic (#38698) (#38703)
backport #38698, fix #38697
2026-07-30 13:41:12 +08:00
9eac9bd032 fix(ui): too many participants shown in commit avatar stacks (#38689) (#38700)
Backport #38689

Show only author and co-authors without committer, and deduplicate the
same user with multiple email addresses.

The commit list "Author" column should not show the committer. This is
somewhat misleading, and arguably showing it on the individual commit
page is sufficient and consistent with other forges.

The same user with multiple email addresses often happens when
DEFAULT_KEEP_EMAIL_PRIVATE is enabled and Gitea does not use an actual
email address by default for edits. Showing the same avatar and name
twice is not helpful then.

Ref #37594
Fix #38488

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-29 21:39:09 +02:00
784d88814f fix: support HEAD requests on Alpine registry APKINDEX.tar.gz (#38686) (#38688)
Backport #38686 by @waterWang

### Description

Fixes #38676

The Alpine package registry registers for only, so a request returns .
Clients that probe the index with before fetching it (like and other
-based tools) fail outright.

**Fix:** Change to for the APKINDEX.tar.gz route, matching the pattern
already used by the i386 registry a few lines below.

### Related issue

Closes #38676

Co-authored-by: water <672684719@qq.com>
Co-authored-by: waterWang <waterWang@users.noreply.github.com>
2026-07-28 14:46:13 +00:00
118 changed files with 2061 additions and 803 deletions
+1 -1
View File
@@ -87,6 +87,7 @@ require (
github.com/prometheus/client_golang v1.23.2
github.com/quasoft/websspi v1.1.2
github.com/redis/go-redis/v9 v9.21.0
github.com/rhysd/actionlint v1.7.12
github.com/robfig/cron/v3 v3.0.1
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
github.com/sassoftware/go-rpmutils v0.4.0
@@ -244,7 +245,6 @@ require (
github.com/prometheus/common v0.68.1 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rhysd/actionlint v1.7.12 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
+25 -4
View File
@@ -9,10 +9,10 @@ package actions
import (
"context"
"errors"
"slices"
"time"
"gitea.dev/models/db"
"gitea.dev/modules/optional"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
@@ -147,7 +147,7 @@ type FindArtifactsOptions struct {
db.ListOptions
RepoID int64
RunID int64
RunAttemptID optional.Option[int64] // use optional to allow filtering by zero (legacy artifacts have run_attempt_id=0)
RunAttemptIDs []int64 // empty means every attempt; pass 0 to target legacy artifacts, which have run_attempt_id=0
ArtifactName string
Status int
FinalizedArtifactsV4 bool
@@ -167,8 +167,8 @@ func (opts FindArtifactsOptions) ToConds() builder.Cond {
if opts.RunID > 0 {
cond = cond.And(builder.Eq{"run_id": opts.RunID})
}
if opts.RunAttemptID.Has() {
cond = cond.And(builder.Eq{"run_attempt_id": opts.RunAttemptID.Value()})
if len(opts.RunAttemptIDs) > 0 {
cond = cond.And(builder.In("run_attempt_id", opts.RunAttemptIDs))
}
if opts.ArtifactName != "" {
cond = cond.And(builder.Eq{"artifact_name": opts.ArtifactName})
@@ -185,6 +185,27 @@ func (opts FindArtifactsOptions) ToConds() builder.Cond {
return cond
}
// FindReadableArtifacts returns the artifacts of opts.RunAttemptIDs, only keeps the ones from a newer attempt.
func FindReadableArtifacts(ctx context.Context, opts FindArtifactsOptions) ([]*ActionArtifact, error) {
arts, err := db.Find[ActionArtifact](ctx, opts)
if err != nil || len(opts.RunAttemptIDs) <= 1 {
return arts, err
}
return keepLatestAttemptArtifacts(arts), nil
}
// keepLatestAttemptArtifacts keeps, per name, only the artifacts of the newest attempt that has it.
// A v3 artifact is one row per uploaded file, so the whole group of the winning attempt is kept.
func keepLatestAttemptArtifacts(arts []*ActionArtifact) []*ActionArtifact {
latest := make(map[string]int64)
for _, art := range arts {
latest[art.ArtifactName] = max(latest[art.ArtifactName], art.RunAttemptID)
}
return slices.DeleteFunc(arts, func(art *ActionArtifact) bool {
return art.RunAttemptID != latest[art.ArtifactName]
})
}
// ActionArtifactMeta is the meta-data of an artifact
type ActionArtifactMeta struct {
ArtifactName string
+27
View File
@@ -0,0 +1,27 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestKeepLatestAttemptArtifacts(t *testing.T) {
arts := []*ActionArtifact{
{ID: 1, RunAttemptID: 1, ArtifactName: "inherited"},
{ID: 2, RunAttemptID: 1, ArtifactName: "shadowed", ArtifactPath: "a.txt"},
{ID: 3, RunAttemptID: 1, ArtifactName: "shadowed", ArtifactPath: "b.txt"},
{ID: 4, RunAttemptID: 2, ArtifactName: "shadowed", ArtifactPath: "c.txt"},
{ID: 5, RunAttemptID: 2, ArtifactName: "own"},
}
// the whole "shadowed" group of attempt 1 is dropped, its multi-file rows must not mix with attempt 2
var ids []int64
for _, art := range keepLatestAttemptArtifacts(arts) {
ids = append(ids, art.ID)
}
assert.Equal(t, []int64{1, 4, 5}, ids)
}
+51
View File
@@ -11,6 +11,7 @@ import (
"gitea.dev/models/db"
user_model "gitea.dev/models/user"
"gitea.dev/modules/container"
"gitea.dev/modules/log"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
@@ -96,6 +97,56 @@ func GetRunAttemptByRunIDAndAttemptNum(ctx context.Context, runID, attemptNum in
return &attempt, nil
}
// GetArtifactAttemptIDs returns the IDs of the attempts whose artifacts the job may read, newest first,
// always including the job's own attempt.
// An attempt that re-ran only some of the run's jobs keeps the artifacts of the attempt it re-ran from,
// because the jobs it passed through never upload them again; a rerun of the whole run starts over.
func GetArtifactAttemptIDs(ctx context.Context, job *ActionRunJob) ([]int64, error) {
if job.Attempt <= 1 || job.RunAttemptID == 0 {
return []int64{job.RunAttemptID}, nil
}
attempts, err := ListRunAttemptsByRunID(ctx, job.RunID)
if err != nil {
return nil, err
}
// a newer attempt is never readable, and attempt 1 has nothing older to continue into
candidateIDs := container.FilterSlice(attempts, func(a *ActionRunAttempt) (int64, bool) {
return a.ID, a.Attempt > 1 && a.Attempt <= job.Attempt
})
passThroughAttemptIDs, err := findPassThroughAttemptIDs(ctx, candidateIDs)
if err != nil {
return nil, err
}
ids := make([]int64, 0, len(attempts))
for _, attempt := range attempts {
if attempt.Attempt > job.Attempt {
continue
}
ids = append(ids, attempt.ID)
if !slices.Contains(passThroughAttemptIDs, attempt.ID) {
// stops at the first attempt that passed no job through
break
}
}
return ids, nil
}
// findPassThroughAttemptIDs narrows the given attempts to those that were a rerun of selected jobs:
// only such a rerun clones jobs carrying a source task.
// TODO: best-effort. Needs a better way to distinguish between "partial re-run" and "full re-run".
func findPassThroughAttemptIDs(ctx context.Context, attemptIDs []int64) ([]int64, error) {
passThroughAttemptIDs := make([]int64, 0, len(attemptIDs))
return passThroughAttemptIDs, db.GetEngine(ctx).
Table("action_run_job").
Cols("run_attempt_id").
In("run_attempt_id", attemptIDs).
Where("source_task_id <> 0").
Distinct("run_attempt_id").
Find(&passThroughAttemptIDs)
}
// FindConcurrentRunAttempts returns attempts in the given concurrency group and status set.
// Results are unordered; callers must not depend on any particular row order.
func FindConcurrentRunAttempts(ctx context.Context, repoID int64, concurrencyGroup string, statuses []Status) ([]*ActionRunAttempt, error) {
+75 -47
View File
@@ -448,58 +448,74 @@ func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, col
return affected, RefreshReusableCallerStatus(ctx, parent)
}
{
// Other goroutines may aggregate the status of the attempt/run and update it too.
// So we need to load the current jobs before updating the aggregate state.
if job.RunAttemptID > 0 {
attempt, err := GetRunAttemptByRepoAndID(ctx, job.RepoID, job.RunAttemptID)
if err != nil {
return 0, err
}
jobs, err := GetRunJobsByRunAndAttemptID(ctx, job.RunID, job.RunAttemptID)
if err != nil {
return 0, err
}
attempt.Status = AggregateJobStatus(jobs)
if attempt.Started.IsZero() && attempt.Status.IsRunning() {
attempt.Started = timeutil.TimeStampNow()
}
if attempt.Stopped.IsZero() && attempt.Status.IsDone() {
attempt.Stopped = timeutil.TimeStampNow()
}
if err := UpdateRunAttempt(ctx, attempt, "status", "started", "stopped"); err != nil {
return 0, fmt.Errorf("update run attempt %d: %w", attempt.ID, err)
}
} else {
// TODO: Remove this fallback in the future.
// Legacy fallback: jobs created before migration v331 have RunAttemptID=0 and are NOT backfilled.
// This path keeps those runs' status consistent when their jobs finish, including:
// - jobs created before migration v331 and complete on the new version starts
// - zombie/abandoned cleanup cron tasks that call UpdateRunJob on legacy jobs
run, err := GetRunByRepoAndID(ctx, job.RepoID, job.RunID)
if err != nil {
return 0, err
}
jobs, err := GetLatestAttemptJobsByRepoAndRunID(ctx, job.RepoID, job.RunID)
if err != nil {
return 0, err
}
run.Status = AggregateJobStatus(jobs)
if run.Started.IsZero() && run.Status.IsRunning() {
run.Started = timeutil.TimeStampNow()
}
if run.Stopped.IsZero() && run.Status.IsDone() {
run.Stopped = timeutil.TimeStampNow()
}
if err := UpdateRun(ctx, run, "status", "started", "stopped"); err != nil {
return 0, fmt.Errorf("update run %d: %w", run.ID, err)
}
}
if err := refreshRunStatus(ctx, job.RepoID, job.RunID, job.RunAttemptID, StatusUnknown); err != nil {
return 0, err
}
return affected, nil
}
// refreshRunStatus recomputes the status of an attempt from the jobs currently stored and persists it.
// The latest attempt propagates its status to its run, an older one only updates itself.
// noJobsStatus settles an attempt without any job, which AggregateJobStatus cannot conclude on its own.
func refreshRunStatus(ctx context.Context, repoID, runID, runAttemptID int64, noJobsStatus Status) error {
// Other goroutines may aggregate the status of the attempt/run and update it too.
// So we need to load the current jobs before updating the aggregate state.
if runAttemptID > 0 {
attempt, err := GetRunAttemptByRepoAndID(ctx, repoID, runAttemptID)
if err != nil {
return err
}
jobs, err := GetRunJobsByRunAndAttemptID(ctx, runID, runAttemptID)
if err != nil {
return err
}
attempt.Status = AggregateJobStatus(jobs)
if len(jobs) == 0 {
attempt.Status = noJobsStatus
}
if attempt.Started.IsZero() && attempt.Status.IsRunning() {
attempt.Started = timeutil.TimeStampNow()
}
if attempt.Stopped.IsZero() && attempt.Status.IsDone() {
attempt.Stopped = timeutil.TimeStampNow()
}
if err := UpdateRunAttempt(ctx, attempt, "status", "started", "stopped"); err != nil {
return fmt.Errorf("update run attempt %d: %w", attempt.ID, err)
}
return nil
}
// TODO: Remove this fallback in the future.
// Legacy fallback: jobs created before migration v331 have RunAttemptID=0 and are NOT backfilled.
// This path keeps those runs' status consistent when their jobs finish, including:
// - jobs created before migration v331 and complete on the new version starts
// - zombie/abandoned cleanup cron tasks that call UpdateRunJob on legacy jobs
// - cancelling a legacy run whose jobs are all already done
run, err := GetRunByRepoAndID(ctx, repoID, runID)
if err != nil {
return err
}
jobs, err := GetLatestAttemptJobsByRepoAndRunID(ctx, repoID, runID)
if err != nil {
return err
}
run.Status = AggregateJobStatus(jobs)
if len(jobs) == 0 {
run.Status = noJobsStatus
}
if run.Started.IsZero() && run.Status.IsRunning() {
run.Started = timeutil.TimeStampNow()
}
if run.Stopped.IsZero() && run.Status.IsDone() {
run.Stopped = timeutil.TimeStampNow()
}
if err := UpdateRun(ctx, run, "status", "started", "stopped"); err != nil {
return fmt.Errorf("update run %d: %w", run.ID, err)
}
return nil
}
// RefreshReusableCallerStatus recomputes a reusable workflow caller's Status, Started and Stopped from its current direct children and persists the change.
// No-op if caller is not a reusable caller.
//
@@ -660,6 +676,8 @@ func CancelPreviousJobsByJobConcurrency(ctx context.Context, job *ActionRunJob)
return CancelJobs(ctx, jobsToCancel)
}
// CancelJobs cancels every cancellable job it is given. It leaves the status of a run it
// cancelled nothing in untouched, SettleRunAfterCancel is what gives such a run a final one.
func CancelJobs(ctx context.Context, jobs []*ActionRunJob) ([]*ActionRunJob, error) {
cancelledJobs := make([]*ActionRunJob, 0, len(jobs))
@@ -684,6 +702,16 @@ func CancelJobs(ctx context.Context, jobs []*ActionRunJob) ([]*ActionRunJob, err
return cancelledJobs, nil
}
// SettleRunAfterCancel gives a run a final status when cancelling it updated no job at all.
// A run's status is otherwise only ever written as a side effect of a job update, so a run whose
// jobs are all done already, or that has no job at all, would stay unfinished forever.
func SettleRunAfterCancel(ctx context.Context, run *ActionRun) error {
if run.Status.IsDone() {
return nil
}
return refreshRunStatus(ctx, run.RepoID, run.ID, run.LatestAttemptID, StatusCancelled)
}
// cancelOneJob cancels a single job and returns the post-cancel row
func cancelOneJob(ctx context.Context, job *ActionRunJob) (*ActionRunJob, error) {
if job.Status.IsDone() {
+89
View File
@@ -8,6 +8,7 @@ import (
"gitea.dev/models/db"
"gitea.dev/models/unittest"
"gitea.dev/modules/timeutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -197,3 +198,91 @@ func TestCancelJobs_NestedBlockedReusableCaller(t *testing.T) {
gotRun := unittest.AssertExistsAndLoadBean(t, &ActionRun{ID: run.ID})
assert.Equal(t, StatusCancelled, gotRun.Status, "run must aggregate to Cancelled, not stay Blocked")
}
func TestSettleRunAfterCancel(t *testing.T) {
// A run that cancelling updates no job in, because its jobs all reached a final status already
// or because it has none at all. Its own row has to be settled explicitly, or the run can never
// finish and can never be deleted either.
newStuckRun := func(t *testing.T, withAttempt, withJob bool) (*ActionRun, []*ActionRunJob) {
t.Helper()
ctx := t.Context()
run := &ActionRun{
Title: "stuck-waiting",
RepoID: 4,
Index: 9801,
OwnerID: 1,
WorkflowID: "test.yaml",
TriggerUserID: 1,
Ref: "refs/heads/master",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
Event: "push",
TriggerEvent: "push",
EventPayload: "{}",
Status: StatusWaiting,
}
require.NoError(t, db.Insert(ctx, run))
var runAttemptID int64
if withAttempt {
attempt := &ActionRunAttempt{RepoID: run.RepoID, RunID: run.ID, Attempt: 1, TriggerUserID: 1, Status: StatusWaiting}
require.NoError(t, db.Insert(ctx, attempt))
run.LatestAttemptID = attempt.ID
require.NoError(t, UpdateRun(ctx, run, "latest_attempt_id"))
runAttemptID = attempt.ID
}
if !withJob {
return run, nil
}
job := &ActionRunJob{
RunID: run.ID,
RunAttemptID: runAttemptID,
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
Name: "job1",
JobID: "job1",
Attempt: 1,
Status: StatusSuccess,
Stopped: timeutil.TimeStampNow(),
}
require.NoError(t, db.Insert(ctx, job))
return run, []*ActionRunJob{job}
}
cases := []struct {
name string
withAttempt bool
withJob bool
want Status
}{
{"done job", true, true, StatusSuccess},
// Runs created before migration v331 have no attempt, their status lives on the run row itself.
{"done job on a legacy run without attempt", false, true, StatusSuccess},
// Aggregation cannot reach a final status without any job, so cancelling has to end the run itself.
{"no job at all", true, false, StatusCancelled},
{"no job at all on a legacy run without attempt", false, false, StatusCancelled},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
run, jobs := newStuckRun(t, tc.withAttempt, tc.withJob)
// mirrors what the CancelRun service does
cancelled, err := CancelJobs(t.Context(), jobs)
require.NoError(t, err)
assert.Empty(t, cancelled, "nothing is cancellable, so the run row has to be settled explicitly")
require.NoError(t, SettleRunAfterCancel(t.Context(), run))
if tc.withAttempt {
gotAttempt := unittest.AssertExistsAndLoadBean(t, &ActionRunAttempt{ID: run.LatestAttemptID})
assert.Equal(t, tc.want, gotAttempt.Status)
}
gotRun := unittest.AssertExistsAndLoadBean(t, &ActionRun{ID: run.ID})
assert.Equal(t, tc.want, gotRun.Status)
assert.NotZero(t, gotRun.Stopped)
})
}
}
+5 -1
View File
@@ -478,7 +478,7 @@ func UpdateTaskByState(ctx context.Context, runnerID int64, state *runnerv1.Task
return nil, err
}
task := &ActionTask{}
err = globallock.LockAndDo(ctx, fmt.Sprintf("UpdateTaskByState-run-%d", runID), func(ctx context.Context) error {
applyState := func(ctx context.Context) error {
if has, err := db.GetEngine(ctx).ID(taskID).Get(task); err != nil {
return err
} else if !has {
@@ -543,6 +543,10 @@ func UpdateTaskByState(ctx context.Context, runnerID int64, state *runnerv1.Task
}
}
return nil
}
err = globallock.LockAndDo(ctx, fmt.Sprintf("UpdateTaskByState-run-%d", runID), func(ctx context.Context) error {
// A half-written report leaves the task done with a running job, which no retry repairs.
return db.WithTx(ctx, applyState)
})
return task, err
}
+33
View File
@@ -4,7 +4,10 @@
package actions
import (
"context"
"errors"
"strings"
"sync/atomic"
"testing"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
@@ -17,6 +20,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/timestamppb"
"xorm.io/xorm/contexts"
)
func TestActionTask_GetRunJobLink(t *testing.T) {
@@ -357,6 +361,35 @@ func TestCreateTaskForRunnerPagination(t *testing.T) {
assert.Equal(t, task.ID, claimed.TaskID)
}
type failFirstStepWrite struct{ fired atomic.Bool }
func (h *failFirstStepWrite) BeforeProcess(c *contexts.ContextHook) (context.Context, error) {
if !h.fired.Load() && strings.HasPrefix(c.SQL, "UPDATE") && strings.Contains(c.SQL, "action_task_step") {
h.fired.Store(true)
return nil, errors.New("interrupted")
}
return c.Ctx, nil
}
func (*failFirstStepWrite) AfterProcess(*contexts.ContextHook) error { return nil }
// TestUpdateTaskByStateIsAtomic checks that an interrupted report writes nothing: a surviving task or
// job write would hit the "state is final" early return, which no retry or cleanup repairs.
func TestUpdateTaskByStateIsAtomic(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
task, job := newRunningTaskForCancelling(t, "atomic-report-job", true)
require.NoError(t, db.Insert(t.Context(), &ActionTaskStep{TaskID: task.ID, RepoID: task.RepoID, Status: StatusRunning}))
unittest.GetXORMEngine().AddHook(&failFirstStepWrite{})
finalState := &runnerv1.TaskState{Id: task.ID, Result: runnerv1.Result_RESULT_SUCCESS, StoppedAt: timestamppb.Now()}
_, err := UpdateTaskByState(t.Context(), task.RunnerID, finalState)
require.Error(t, err)
assert.Equal(t, StatusRunning, unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID}).Status)
assert.Equal(t, StatusRunning, unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID}).Status)
_, err = UpdateTaskByState(t.Context(), task.RunnerID, finalState)
require.NoError(t, err)
assert.Equal(t, StatusSuccess, unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID}).Status)
}
// newRunningTaskForCancelling inserts a running run/job/task assigned to a fresh runner,
// which is the state every cancellation test starts from.
func newRunningTaskForCancelling(t *testing.T, name string, hasCancellingSupport bool) (*ActionTask, *ActionRunJob) {
+8
View File
@@ -7,6 +7,7 @@ import (
"context"
"gitea.dev/models/user"
"gitea.dev/modules/container"
"gitea.dev/modules/git"
"gitea.dev/modules/log"
)
@@ -33,11 +34,18 @@ func BuildAvatarStackData(ctx context.Context, allParticipants []*git.CommitIden
ret := &AvatarStackData{
Participants: make([]*CommitParticipant, 0, len(allParticipants)),
}
uniqueUserIDs := make(container.Set[int64])
for _, p := range allParticipants {
var giteaUser *user.User
if emailUserMap != nil {
giteaUser = emailUserMap.GetByEmail(p.Email)
}
if giteaUser != nil {
// identities without a Gitea account can only be compared by their git identity
if !uniqueUserIDs.Add(giteaUser.ID) {
continue
}
}
ret.Participants = append(ret.Participants, &CommitParticipant{GiteaUser: giteaUser, GitIdentity: p})
}
return ret
+2 -3
View File
@@ -39,8 +39,7 @@ func GetUserCommitsByGitCommits(ctx context.Context, gitCommits []*git.Commit, r
emailSet := make(container.Set[string])
for _, c := range gitCommits {
emailSet.Add(c.Author.Email)
emailSet.Add(c.Committer.Email)
for _, p := range c.AllParticipantIdentities() {
for _, p := range c.AllAuthorIdentities() {
emailSet.Add(p.Email)
}
}
@@ -55,7 +54,7 @@ func GetUserCommitsByGitCommits(ctx context.Context, gitCommits []*git.Commit, r
uc := &UserCommit{
AuthorUser: emailUserMap.GetByEmail(c.Author.Email), // FIXME: why GetUserCommitsByGitCommits uses "Author", but ParseCommitsWithSignature uses "Committer"?
GitCommit: c,
AvatarStackData: BuildAvatarStackData(ctx, c.AllParticipantIdentities(), emailUserMap),
AvatarStackData: BuildAvatarStackData(ctx, c.AllAuthorIdentities(), emailUserMap),
}
uc.AvatarStackData.SearchByEmailLink = searchByEmailLink
userCommits = append(userCommits, uc)
+12 -8
View File
@@ -7,41 +7,45 @@ import (
"context"
"io"
repo_model "gitea.dev/models/repo"
"gitea.dev/modules/git"
"gitea.dev/modules/gitrepo"
"gitea.dev/modules/log"
)
type commitChecker struct {
ctx context.Context
commitCache map[string]bool
gitRepoFacade gitrepo.Repository
ctx context.Context
commitCache map[string]bool
repoOptional *repo_model.Repository
gitRepo *git.Repository
gitRepoCloser io.Closer
}
func newCommitChecker(ctx context.Context, gitRepo gitrepo.Repository) *commitChecker {
return &commitChecker{ctx: ctx, commitCache: make(map[string]bool), gitRepoFacade: gitRepo}
func newCommitChecker(ctx context.Context, repo *repo_model.Repository) *commitChecker {
return &commitChecker{ctx: ctx, commitCache: make(map[string]bool), repoOptional: repo}
}
func (c *commitChecker) Close() error {
if c != nil && c.gitRepoCloser != nil {
if c.gitRepoCloser != nil {
return c.gitRepoCloser.Close()
}
return nil
}
func (c *commitChecker) IsCommitIDExisting(commitID string) bool {
if c.repoOptional == nil {
return false
}
exist, inCache := c.commitCache[commitID]
if inCache {
return exist
}
if c.gitRepo == nil {
r, closer, err := gitrepo.RepositoryFromContextOrOpen(c.ctx, c.gitRepoFacade)
r, closer, err := gitrepo.RepositoryFromContextOrOpen(c.ctx, c.repoOptional)
if err != nil {
log.Error("unable to open repository: %s Error: %v", gitrepo.RepoGitURL(c.gitRepoFacade), err)
log.Error("unable to open repository: %s Error: %v", gitrepo.RepoGitURL(c.repoOptional), err)
return false
}
c.gitRepo, c.gitRepoCloser = r, closer
+1 -1
View File
@@ -51,10 +51,10 @@ func NewRenderContextRepoComment(ctx context.Context, repo *repo_model.Repositor
helper := &RepoComment{opts: util.OptionalArg(opts)}
rctx := markup.NewRenderContext(ctx)
helper.ctx = rctx
helper.commitChecker = newCommitChecker(ctx, repo)
var metas map[string]string
if repo != nil {
helper.repoLink = repo.Link()
helper.commitChecker = newCommitChecker(ctx, repo)
metas = repo.ComposeCommentMetas(ctx)
} else {
// repo can be nil when rendering a commit message in user's dashboard feedback whose repository has been deleted
+4 -4
View File
@@ -35,11 +35,11 @@ func (r *RepoFile) ResolveLink(link, preferLinkType string) (finalLink string) {
case markup.LinkTypeRoot:
finalLink = r.ctx.ResolveLinkRoot(link)
case markup.LinkTypeRaw:
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "raw", r.opts.CurrentRefSubURL), r.opts.CurrentTreePath, link)
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "raw", r.opts.CurrentRefSubURL), util.PathEscapeSegments(r.opts.CurrentTreePath), link)
case markup.LinkTypeMedia:
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "media", r.opts.CurrentRefSubURL), r.opts.CurrentTreePath, link)
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "media", r.opts.CurrentRefSubURL), util.PathEscapeSegments(r.opts.CurrentTreePath), link)
default:
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "src", r.opts.CurrentRefSubURL), r.opts.CurrentTreePath, link)
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "src", r.opts.CurrentRefSubURL), util.PathEscapeSegments(r.opts.CurrentTreePath), link)
}
return finalLink
}
@@ -58,9 +58,9 @@ func NewRenderContextRepoFile(ctx context.Context, repo *repo_model.Repository,
helper := &RepoFile{opts: util.OptionalArg(opts)}
rctx := markup.NewRenderContext(ctx)
helper.ctx = rctx
helper.commitChecker = newCommitChecker(ctx, repo)
if repo != nil {
helper.repoLink = repo.Link()
helper.commitChecker = newCommitChecker(ctx, repo)
rctx = rctx.WithMetas(repo.ComposeRepoFileMetas(ctx))
} else {
// this is almost dead code, only to pass the incorrect tests
+6 -6
View File
@@ -68,7 +68,7 @@ func TestRepoFile(t *testing.T) {
t.Run("WithCurrentRefSubURLByTag", func(t *testing.T) {
rctx := NewRenderContextRepoFile(t.Context(), repo1, RepoFileOptions{
CurrentRefSubURL: "/commit/1234",
CurrentTreePath: "my-dir",
CurrentTreePath: "my dir",
}).
WithMarkupType(markdown.MarkupName)
rendered, err := testRenderString(rctx, `
@@ -76,8 +76,8 @@ func TestRepoFile(t *testing.T) {
<video src="LINK">
`)
assert.NoError(t, err)
assert.Equal(t, `<a href="/user2/repo1/src/commit/1234/my-dir/LINK" target="_blank" rel="nofollow noopener"><img src="/user2/repo1/media/commit/1234/my-dir/LINK"/></a>
<video src="/user2/repo1/media/commit/1234/my-dir/LINK">
assert.Equal(t, `<a href="/user2/repo1/src/commit/1234/my%20dir/LINK" target="_blank" rel="nofollow noopener"><img src="/user2/repo1/media/commit/1234/my%20dir/LINK"/></a>
<video src="/user2/repo1/media/commit/1234/my%20dir/LINK">
</video>`, rendered)
})
}
@@ -89,8 +89,8 @@ func TestRepoFileOrgMode(t *testing.T) {
t.Run("Links", func(t *testing.T) {
rctx := NewRenderContextRepoFile(t.Context(), repo1, RepoFileOptions{
CurrentRefSubURL: "/commit/1234",
CurrentTreePath: "my-dir",
}).WithRelativePath("my-dir/a.org")
CurrentTreePath: "my dir",
}).WithRelativePath("my dir/a.org")
rendered, err := testRenderString(rctx, `
[[https://google.com/]]
@@ -99,7 +99,7 @@ func TestRepoFileOrgMode(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, `<p>
<a href="https://google.com/" rel="nofollow">https://google.com/</a>
<a href="/user2/repo1/src/commit/1234/my-dir/ImageLink.svg" rel="nofollow">The Image Desc</a></p>
<a href="/user2/repo1/src/commit/1234/my%20dir/ImageLink.svg" rel="nofollow">The Image Desc</a></p>
`, rendered)
})
+3 -3
View File
@@ -36,9 +36,9 @@ func (r *RepoWiki) ResolveLink(link, preferLinkType string) (finalLink string) {
case markup.LinkTypeRoot:
finalLink = r.ctx.ResolveLinkRoot(link)
case markup.LinkTypeMedia, markup.LinkTypeRaw:
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "wiki/raw", r.opts.currentRefSubURL), r.opts.currentTreePath, link)
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "wiki/raw", r.opts.currentRefSubURL), util.PathEscapeSegments(r.opts.currentTreePath), link)
default:
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "wiki", r.opts.currentRefSubURL), r.opts.currentTreePath, link)
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "wiki", r.opts.currentRefSubURL), util.PathEscapeSegments(r.opts.currentTreePath), link)
}
return finalLink
}
@@ -57,9 +57,9 @@ type RepoWikiOptions struct {
func NewRenderContextRepoWiki(ctx context.Context, repo *repo_model.Repository, opts ...RepoWikiOptions) *markup.RenderContext {
helper := &RepoWiki{opts: util.OptionalArg(opts)}
rctx := markup.NewRenderContext(ctx).WithMarkupType(markdown.MarkupName)
helper.commitChecker = newCommitChecker(ctx, repo)
if repo != nil {
helper.repoLink = repo.Link()
helper.commitChecker = newCommitChecker(ctx, repo)
rctx = rctx.WithMetas(repo.ComposeWikiMetas(ctx))
} else {
// this is almost dead code, only to pass the incorrect tests
+5 -3
View File
@@ -50,14 +50,16 @@ func TestRepoWiki(t *testing.T) {
})
t.Run("PathInTag", func(t *testing.T) {
rctx := NewRenderContextRepoWiki(t.Context(), repo1).WithMarkupType(markdown.MarkupName)
rctx := NewRenderContextRepoWiki(t.Context(), repo1, RepoWikiOptions{
currentTreePath: "my dir",
}).WithMarkupType(markdown.MarkupName)
rendered, err := testRenderString(rctx, `
<img src="LINK">
<video src="LINK">
`)
assert.NoError(t, err)
assert.Equal(t, `<a href="/user2/repo1/wiki/LINK" target="_blank" rel="nofollow noopener"><img src="/user2/repo1/wiki/raw/LINK"/></a>
<video src="/user2/repo1/wiki/raw/LINK">
assert.Equal(t, `<a href="/user2/repo1/wiki/my%20dir/LINK" target="_blank" rel="nofollow noopener"><img src="/user2/repo1/wiki/raw/my%20dir/LINK"/></a>
<video src="/user2/repo1/wiki/raw/my%20dir/LINK">
</video>`, rendered)
})
}
-33
View File
@@ -111,39 +111,6 @@ func IsCollaborator(ctx context.Context, repoID, userID int64) (bool, error) {
return db.Exist[Collaboration](ctx, builder.Eq{"repo_id": repoID, "user_id": userID})
}
// ChangeCollaborationAccessMode sets new access mode for the collaboration.
func ChangeCollaborationAccessMode(ctx context.Context, repo *Repository, uid int64, mode perm.AccessMode) error {
// Discard invalid input
if mode <= perm.AccessModeNone || mode > perm.AccessModeOwner {
return nil
}
return db.WithTx(ctx, func(ctx context.Context) error {
collaboration, has, err := db.Get[Collaboration](ctx, builder.Eq{"repo_id": repo.ID, "user_id": uid})
if err != nil {
return fmt.Errorf("get collaboration: %w", err)
} else if !has {
return nil
}
if collaboration.Mode == mode {
return nil
}
collaboration.Mode = mode
if _, err = db.GetEngine(ctx).
ID(collaboration.ID).
Cols("mode").
Update(collaboration); err != nil {
return fmt.Errorf("update collaboration: %w", err)
} else if _, err = db.Exec(ctx, "UPDATE access SET mode = ? WHERE user_id = ? AND repo_id = ?", mode, uid, repo.ID); err != nil {
return fmt.Errorf("update access table: %w", err)
}
return nil
})
}
// IsOwnerMemberCollaborator checks if a provided user is the owner, a collaborator or a member of a team in a repository
func IsOwnerMemberCollaborator(ctx context.Context, repo *Repository, userID int64) (bool, error) {
if repo.OwnerID == userID {
-24
View File
@@ -7,8 +7,6 @@ import (
"testing"
"gitea.dev/models/db"
"gitea.dev/models/perm"
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
@@ -69,28 +67,6 @@ func TestRepository_IsCollaborator(t *testing.T) {
test(4, 4, true)
}
func TestRepository_ChangeCollaborationAccessMode(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
assert.NoError(t, repo_model.ChangeCollaborationAccessMode(t.Context(), repo, 4, perm.AccessModeAdmin))
collaboration := unittest.AssertExistsAndLoadBean(t, &repo_model.Collaboration{RepoID: repo.ID, UserID: 4})
assert.Equal(t, perm.AccessModeAdmin, collaboration.Mode)
access := unittest.AssertExistsAndLoadBean(t, &access_model.Access{UserID: 4, RepoID: repo.ID})
assert.Equal(t, perm.AccessModeAdmin, access.Mode)
assert.NoError(t, repo_model.ChangeCollaborationAccessMode(t.Context(), repo, 4, perm.AccessModeAdmin))
assert.NoError(t, repo_model.ChangeCollaborationAccessMode(t.Context(), repo, unittest.NonexistentID, perm.AccessModeAdmin))
// Discard invalid input.
assert.NoError(t, repo_model.ChangeCollaborationAccessMode(t.Context(), repo, 4, perm.AccessMode(-1)))
unittest.CheckConsistencyFor(t, &repo_model.Repository{ID: repo.ID})
}
func TestRepository_IsOwnerMemberCollaborator(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
+161 -82
View File
@@ -6,7 +6,10 @@ package jobparser
import (
"errors"
"fmt"
"math"
"reflect"
"regexp"
"strconv"
"strings"
"gitea.com/gitea/runner/act/exprparser"
@@ -23,12 +26,6 @@ func NewExpressionEvaluator(interpreter exprparser.Interpreter) *ExpressionEvalu
return &ExpressionEvaluator{interpreter: interpreter}
}
func (ee ExpressionEvaluator) evaluate(in string, defaultStatusCheck exprparser.DefaultStatusCheck) (any, error) {
evaluated, err := ee.interpreter.Evaluate(in, defaultStatusCheck)
return evaluated, err
}
func (ee ExpressionEvaluator) evaluateScalarYamlNode(node *yaml.Node) error {
var in string
if err := node.Decode(&in); err != nil {
@@ -37,17 +34,17 @@ func (ee ExpressionEvaluator) evaluateScalarYamlNode(node *yaml.Node) error {
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
return nil
}
expr, _ := rewriteSubExpression(in, false)
res, err := ee.evaluate(expr, exprparser.DefaultStatusCheckNone)
res, err := ee.evaluateScalar(in)
if err != nil {
return err
}
return node.Encode(res)
}
// GitHub has this undocumented feature to merge maps, called insert directive
var insertDirective = regexp.MustCompile(`\${{\s*insert\s*}}`)
func (ee ExpressionEvaluator) evaluateMappingYamlNode(node *yaml.Node) error {
// GitHub has this undocumented feature to merge maps, called insert directive
insertDirective := regexp.MustCompile(`\${{\s*insert\s*}}`)
for i := 0; i < len(node.Content)/2; {
k := node.Content[i*2]
v := node.Content[i*2+1]
@@ -102,88 +99,170 @@ func (ee ExpressionEvaluator) EvaluateYamlNode(node *yaml.Node) error {
}
}
func (ee ExpressionEvaluator) Interpolate(in string) string {
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
return in
}
expr, _ := rewriteSubExpression(in, true)
evaluated, err := ee.evaluate(expr, exprparser.DefaultStatusCheckNone)
// interpolate evaluates every part on its own, so a malformed one cannot restructure its neighbours
func (ee ExpressionEvaluator) interpolate(in string) (string, error) {
parts, err := splitSubExpressions(in)
if err != nil {
return ""
return "", err
}
value, ok := evaluated.(string)
if !ok {
panic(fmt.Sprintf("Expression %s did not evaluate to a string", expr))
}
return value
}
func escapeFormatString(in string) string {
return strings.ReplaceAll(strings.ReplaceAll(in, "{", "{{"), "}", "}}")
}
func rewriteSubExpression(in string, forceFormat bool) (string, error) {
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
if len(parts) == 1 && !parts[0].isExpr {
return in, nil
}
var out strings.Builder
out.Grow(len(in))
for _, part := range parts {
if !part.isExpr {
out.WriteString(part.text)
continue
}
evaluated, err := ee.interpreter.Evaluate(part.text, exprparser.DefaultStatusCheckNone)
if err != nil {
return "", err
}
out.WriteString(coerceToString(evaluated))
}
return out.String(), nil
}
strPattern := regexp.MustCompile("(?:''|[^'])*'")
pos := 0
exprStart := -1
strStart := -1
var results []string
var formatOut strings.Builder
for pos < len(in) {
if strStart > -1 {
matches := strPattern.FindStringIndex(in[pos:])
if matches == nil {
return "", errors.New("unclosed string")
}
// evaluateScalar keeps the type of a lone expression, so `${{ fromJSON('[1,2]') }}` stays an array
func (ee ExpressionEvaluator) evaluateScalar(in string) (any, error) {
parts, err := splitSubExpressions(in)
if err != nil {
return nil, err
}
if len(parts) == 1 && parts[0].isExpr {
return ee.interpreter.Evaluate(parts[0].text, exprparser.DefaultStatusCheckNone)
}
return ee.interpolate(in)
}
strStart = -1
pos += matches[1]
} else if exprStart > -1 {
exprEnd := strings.Index(in[pos:], "}}")
strStart = strings.Index(in[pos:], "'")
// evaluateCondition evaluates an `if:`, an expression even without `${{ }}`. Mixed content
// interpolates to a string, so the success() default applies to it separately.
func (ee ExpressionEvaluator) evaluateCondition(in string) (bool, error) {
parts, err := splitSubExpressions(in)
if err != nil {
return false, err
}
if len(parts) == 1 {
evaluated, err := ee.interpreter.Evaluate(parts[0].text, exprparser.DefaultStatusCheckSuccess)
if err != nil {
return false, err
}
return exprparser.IsTruthy(evaluated), nil
}
if exprEnd > -1 && strStart > -1 {
if exprEnd < strStart {
strStart = -1
} else {
exprEnd = -1
}
}
if exprEnd > -1 {
fmt.Fprintf(&formatOut, "{%d}", len(results))
results = append(results, strings.TrimSpace(in[exprStart:pos+exprEnd]))
pos += exprEnd + 2
exprStart = -1
} else if strStart > -1 {
pos += strStart + 1
} else {
panic("unclosed expression.")
}
} else {
exprStart = strings.Index(in[pos:], "${{")
if exprStart != -1 {
formatOut.WriteString(escapeFormatString(in[pos : pos+exprStart]))
exprStart = pos + exprStart + 3
pos = exprStart
} else {
formatOut.WriteString(escapeFormatString(in[pos:]))
pos = len(in)
}
// mixed content is a string, so the success() default applies to it separately
if !expressionCallsFunction(in, "success", "always", "failure", "cancelled") {
status, err := ee.interpreter.Evaluate("success()", exprparser.DefaultStatusCheckNone)
if err != nil {
return false, err
}
if !exprparser.IsTruthy(status) {
return false, nil
}
}
interpolated, err := ee.interpolate(in)
if err != nil {
return false, err
}
return exprparser.IsTruthy(interpolated), nil
}
if len(results) == 1 && formatOut.String() == "{0}" && !forceFormat {
return in, nil
// coerceToString converts an evaluated expression value to a string the way GitHub does,
// see https://docs.github.com/en/actions/reference/workflows-and-actions/expressions#operators
// An already reflected value is accepted as-is, since Interface() would panic on an invalid one.
func coerceToString(v any) string {
value, ok := v.(reflect.Value)
if !ok {
value = reflect.ValueOf(v)
}
out := fmt.Sprintf("format('%s', %s)", strings.ReplaceAll(formatOut.String(), "'", "''"), strings.Join(results, ", "))
return out, nil
switch value.Kind() {
case reflect.Invalid:
return ""
case reflect.Bool:
return strconv.FormatBool(value.Bool())
case reflect.String:
return value.String()
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return strconv.FormatInt(value.Int(), 10)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return strconv.FormatUint(value.Uint(), 10)
case reflect.Float32, reflect.Float64:
if math.IsInf(value.Float(), 1) {
return "Infinity"
} else if math.IsInf(value.Float(), -1) {
return "-Infinity"
}
return fmt.Sprintf("%.15G", value.Float())
case reflect.Slice, reflect.Array:
return "Array"
// contexts such as `github` are pointers to structs, so they stringify as objects too
case reflect.Map, reflect.Struct:
return "Object"
case reflect.Interface, reflect.Pointer:
if value.IsNil() {
return ""
}
return coerceToString(value.Elem())
}
return fmt.Sprintf("%v", value)
}
type exprPart struct {
text string
isExpr bool
}
// splitSubExpressions splits in the way GitHub's template reader does, leaving a value without a
// complete expression literal.
func splitSubExpressions(in string) ([]exprPart, error) {
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
return []exprPart{{text: in}}, nil
}
parts := make([]exprPart, 0, 2*strings.Count(in, "${{")+1)
for {
start := strings.Index(in, "${{")
if start < 0 {
if in != "" {
parts = append(parts, exprPart{text: in})
}
return parts, nil
}
if start > 0 {
parts = append(parts, exprPart{text: in[:start]})
}
rest := in[start+len("${{"):]
end := indexExprEnd(rest)
if end < 0 {
return nil, errors.New("unclosed expression")
}
parts = append(parts, exprPart{text: strings.TrimSpace(rest[:end]), isExpr: true})
in = rest[end+len("}}"):]
}
}
// indexExprEnd returns the offset of the `}}` ending an expression, or -1. A quote toggles string
// state, so a `}}` inside a string does not end it.
func indexExprEnd(in string) int {
inString := false
for i := range len(in) {
switch {
case in[i] == '\'':
inString = !inString
case !inString && in[i] == '}' && i+1 < len(in) && in[i+1] == '}':
return i
}
}
return -1
}
+44 -7
View File
@@ -6,11 +6,13 @@ package jobparser
import (
"bytes"
"fmt"
"slices"
"sort"
"strings"
"gitea.com/gitea/runner/act/exprparser"
"gitea.com/gitea/runner/act/model"
"github.com/rhysd/actionlint"
"go.yaml.in/yaml/v4"
)
@@ -48,7 +50,9 @@ func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
}
evaluator := NewExpressionEvaluator(exprparser.NewInterpeter(&exprparser.EvaluationEnvironment{Github: pc.gitContext, Vars: pc.vars, Inputs: pc.inputs}, exprparser.Config{}))
workflow.RunName = evaluator.Interpolate(workflow.RunName)
if workflow.RunName, err = evaluator.interpolate(workflow.RunName); err != nil {
return nil, fmt.Errorf("interpolate run-name: %w", err)
}
for i, id := range ids {
job := jobs[i]
@@ -63,10 +67,14 @@ func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
}
job.Strategy.RawMatrix = encodeMatrix(matrix)
evaluator := NewExpressionEvaluator(NewInterpeter(id, origin.GetJob(id), matrix, pc.gitContext, results, pc.vars, pc.inputs))
job.Name = nameWithMatrix(job.Name, matrix, evaluator)
if job.Name, err = nameWithMatrix(job.Name, matrix, evaluator); err != nil {
return nil, fmt.Errorf("interpolate name for job %q: %w", id, err)
}
runsOn := origin.GetJob(id).RunsOn()
for i, v := range runsOn {
runsOn[i] = evaluator.Interpolate(v)
if runsOn[i], err = evaluator.interpolate(v); err != nil {
return nil, fmt.Errorf("interpolate runs-on for job %q: %w", id, err)
}
}
job.RawRunsOn = encodeRunsOn(runsOn)
if err := evaluator.EvaluateYamlNode(&job.RawContinueOnError); err != nil {
@@ -150,16 +158,45 @@ func encodeRunsOn(runsOn []string) yaml.Node {
return node
}
func nameWithMatrix(name string, m map[string]any, evaluator *ExpressionEvaluator) string {
func nameWithMatrix(name string, m map[string]any, evaluator *ExpressionEvaluator) (string, error) {
if len(m) == 0 {
return name
return name, nil
}
if !strings.Contains(name, "${{") || !strings.Contains(name, "}}") {
return name + " " + matrixName(m)
return name + " " + matrixName(m), nil
}
return evaluator.Interpolate(name)
return evaluator.interpolate(name)
}
// expressionCallsFunction reports whether any ${{ }} expression in value calls one of the functions.
func expressionCallsFunction(value string, names ...string) bool {
parts, err := splitSubExpressions(value)
if err != nil {
return true // unparseable here, let the expansion report it against the real values
}
for _, part := range parts {
if !part.isExpr {
continue
}
// The lexer needs the closing `}}` that the scanner strips.
expr, err := actionlint.NewExprParser().Parse(actionlint.NewExprLexer(part.text + "}}"))
if err != nil {
return true // unparseable here, let the expansion report it against the real values
}
found := false
actionlint.VisitExprNode(expr, func(node, _ actionlint.ExprNode, entering bool) {
call, ok := node.(*actionlint.FuncCallNode)
if entering && ok && slices.Contains(names, strings.ToLower(call.Callee)) {
found = true
}
})
if found {
return true
}
}
return false
}
func matrixName(m map[string]any) string {
@@ -7,6 +7,7 @@ import (
"strings"
"testing"
"gitea.com/gitea/runner/act/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.yaml.in/yaml/v4"
@@ -107,3 +108,42 @@ func TestParse(t *testing.T) {
})
}
}
func TestParseInterpolatesRunName(t *testing.T) {
workflow := func(runName string) []byte {
return []byte("name: t\nrun-name: \"" + runName + "\"\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n steps: [{run: echo}]\n")
}
for _, tt := range []struct{ name, runName, want string }{
{"bool", "${{ true }}", "true"},
{"int", "${{ 1 }}", "1"},
{"float", "${{ 1.0 }}", "1"},
{"null", "${{ null }}", ""},
{"object", `${{ fromJSON('{\"a\":1}') }}`, "Object"},
{"array", "${{ fromJSON('[1,2]') }}", "Array"},
{"context", "${{ github }}", "Object"},
{"surrounding literals", "run ${{ 1 }} now", "run 1 now"},
{"two expressions", "${{ 1 }}-${{ true }}", "1-true"},
{"closing brace inside a string", "${{ 'a}}b' }}", "a}}b"},
{"incomplete expression stays literal", "${{ 1", "${{ 1"},
} {
t.Run(tt.name, func(t *testing.T) {
result, err := Parse(workflow(tt.runName), WithGitContext(&model.GithubContext{EventName: "push"}))
require.NoError(t, err)
require.Len(t, result, 1)
assert.Equal(t, tt.want, result[0].RunName)
})
}
// a malformed part must not restructure the surrounding expression
for _, runName := range []string{"${{ 1) && (2 }}", "run ${{ 1) && (2 }} now", "${{ 'a' }} ${{ b", "${{ 'a }}"} {
_, err := Parse(workflow(runName), WithGitContext(&model.GithubContext{EventName: "push"}))
assert.ErrorContains(t, err, "interpolate run-name")
}
// callers such as commit status parse without a git context, leaving `github` a nil pointer
result, err := Parse(workflow("${{ github }}"))
require.NoError(t, err)
require.Len(t, result, 1)
assert.Empty(t, result[0].RunName)
}
+1 -10
View File
@@ -8,7 +8,6 @@ import (
"errors"
"fmt"
"gitea.com/gitea/runner/act/exprparser"
"gitea.com/gitea/runner/act/model"
"go.yaml.in/yaml/v4"
)
@@ -518,15 +517,7 @@ func EvaluateJobIfExpression(jobID string, job *Job, gitCtx map[string]any, resu
matrix = matrixes[0]
}
evaluator := NewExpressionEvaluator(NewInterpeter(jobID, actJob, matrix, toGitContext(gitCtx), results, vars, inputs))
expr, err := rewriteSubExpression(job.If.Value, false)
if err != nil {
return false, err
}
result, err := evaluator.evaluate(expr, exprparser.DefaultStatusCheckSuccess)
if err != nil {
return false, err
}
return exprparser.IsTruthy(result), nil
return evaluator.evaluateCondition(job.If.Value)
}
// parseMappingNode parse a mapping node and preserve order.
+4
View File
@@ -527,6 +527,10 @@ func TestEvaluateJobIfExpression(t *testing.T) {
{name: "cancelled", ifCond: "${{ cancelled() }}", needResult: "success", expected: false},
{name: "not cancelled or failure", ifCond: "${{ !(cancelled() || failure()) }}", needResult: "success", expected: true},
{name: "not cancelled or failure, need failed", ifCond: "${{ !(cancelled() || failure()) }}", needResult: "failure", expected: false},
// a condition is an expression with or without `${{ }}`, literal text around one makes it a string
{name: "bare expression", ifCond: "always()", needResult: "failure", expected: true},
{name: "literal text keeps the success() default", ifCond: "x ${{ 1 }}", needResult: "failure", expected: false},
{name: "literal text around a status function drops it", ifCond: "x ${{ always() }}", needResult: "failure", expected: true},
}
for _, kase := range kases {
t.Run(kase.name, func(t *testing.T) {
+3 -29
View File
@@ -260,7 +260,7 @@ func MatchCallerInputsAgainstSpec(spec *WorkflowCallSpec, evaluated map[string]a
func parseWorkflowCallInput(name string, typ InputType, v any) (any, error) {
switch typ {
case InputTypeString:
return toString(v), nil
return coerceToString(v), nil
case InputTypeBoolean:
// strict type matching: a boolean input only accepts a native bool, not a "true"/"false" string
if b, ok := v.(bool); ok {
@@ -361,11 +361,11 @@ func EvaluateWorkflowCallOutputs(spec *WorkflowCallSpec, gitCtx *model.GithubCon
Vars: vars,
Inputs: inputs,
}
interpreter := exprparser.NewInterpeter(env, exprparser.Config{})
evaluator := NewExpressionEvaluator(exprparser.NewInterpeter(env, exprparser.Config{}))
out := make(map[string]string, len(spec.Outputs))
for name, o := range spec.Outputs {
v, err := evaluateWorkflowCallOutputValue(interpreter, o.Value)
v, err := evaluator.interpolate(o.Value)
if err != nil {
return nil, fmt.Errorf("workflow_call output %q: %w", name, err)
}
@@ -373,29 +373,3 @@ func EvaluateWorkflowCallOutputs(spec *WorkflowCallSpec, gitCtx *model.GithubCon
}
return out, nil
}
func evaluateWorkflowCallOutputValue(interpreter exprparser.Interpreter, value string) (string, error) {
if !strings.Contains(value, "${{") || !strings.Contains(value, "}}") {
return value, nil
}
expr, err := rewriteSubExpression(value, true)
if err != nil {
return "", err
}
evaluated, err := interpreter.Evaluate(expr, exprparser.DefaultStatusCheckNone)
if err != nil {
return "", err
}
return toString(evaluated), nil
}
func toString(v any) string {
switch s := v.(type) {
case string:
return s
case nil:
return ""
default:
return fmt.Sprintf("%v", s)
}
}
+5 -3
View File
@@ -59,6 +59,7 @@ func ParseScopedWorkflows(sourceCommit *git.Commit) ([]*ParsedScopedWorkflow, er
// It returns the workflows whose `on:` matches, and those that matched the event but were excluded by a branch/paths filter (filtered).
func MatchScopedWorkflows(
parsed []*ParsedScopedWorkflow,
sourceCommitSHA string,
consumerGitRepo *git.Repository,
consumerCommit *git.Commit,
triggedEvent webhook_module.HookEventType,
@@ -71,9 +72,10 @@ func MatchScopedWorkflows(
continue
}
dwf := &DetectedWorkflow{
EntryName: p.EntryName,
TriggerEvent: evt,
Content: p.Content,
EntryName: p.EntryName,
TriggerEvent: evt,
Content: p.Content,
SourceCommitSHA: sourceCommitSHA,
}
switch detectWorkflowMatch(consumerGitRepo, consumerCommit, triggedEvent, payload, evt) {
case detectMatched:
+14 -9
View File
@@ -28,6 +28,8 @@ type DetectedWorkflow struct {
EntryName string
TriggerEvent *jobparser.Event
Content []byte
// SourceCommitSHA is the commit Content was read from, and must always be filled in together with Content.
SourceCommitSHA string
}
type detectResult int
@@ -203,17 +205,19 @@ func DetectWorkflows(
if evt.IsSchedule() {
if detectSchedule {
dwf := &DetectedWorkflow{
EntryName: entry.Name(),
TriggerEvent: evt,
Content: content,
EntryName: entry.Name(),
TriggerEvent: evt,
Content: content,
SourceCommitSHA: commit.ID.String(),
}
schedules = append(schedules, dwf)
}
} else {
dwf := &DetectedWorkflow{
EntryName: entry.Name(),
TriggerEvent: evt,
Content: content,
EntryName: entry.Name(),
TriggerEvent: evt,
Content: content,
SourceCommitSHA: commit.ID.String(),
}
switch detectWorkflowMatch(gitRepo, commit, triggedEvent, payload, evt) {
case detectMatched:
@@ -252,9 +256,10 @@ func DetectScheduledWorkflows(gitRepo *git.Repository, commit *git.Commit) ([]*D
if evt.IsSchedule() {
log.Trace("detect scheduled workflow: %q", entry.Name())
dwf := &DetectedWorkflow{
EntryName: entry.Name(),
TriggerEvent: evt,
Content: content,
EntryName: entry.Name(),
TriggerEvent: evt,
Content: content,
SourceCommitSHA: commit.ID.String(),
}
wfs = append(wfs, dwf)
}
+4 -7
View File
@@ -28,13 +28,10 @@ func Init() {
WebAuthn = &webauthn.WebAuthn{
Config: &webauthn.Config{
RPDisplayName: setting.AppName,
RPID: setting.Domain,
RPOrigins: []string{appURL},
AuthenticatorSelection: protocol.AuthenticatorSelection{
UserVerification: protocol.VerificationDiscouraged,
},
AttestationPreference: protocol.PreferDirectAttestation,
RPDisplayName: setting.AppName,
RPID: setting.Domain,
RPOrigins: []string{appURL},
AttestationPreference: protocol.PreferNoAttestation, // Gitea never verifies attestation
},
}
}
+29 -44
View File
@@ -39,9 +39,7 @@ type CommitMessage struct {
trailerValues CommitMessageTrailerValues
allParticipants []*CommitIdentity
committerCoAuthorIdx int
committerCoAuthor *CommitIdentity
allAuthors []*CommitIdentity
}
func (c *CommitMessage) MessageUTF8() string {
@@ -146,63 +144,50 @@ func CommitMessageParseTrailer(s string) CommitMessageTrailerValues {
return ret
}
// AllParticipantIdentities returns all the participants in the commit, the first one is the commit's author
func (c *Commit) AllParticipantIdentities() []*CommitIdentity {
if c.allParticipants != nil {
return c.allParticipants
// AllAuthorIdentities returns all the author and co-authors in the commit. Committer is not included:
// * Author & Co-author: they changed the code (attribution)
// * Committer: they submitted the commit but didn't change the code (e.g.: maintainer signed a commit)
func (c *Commit) AllAuthorIdentities() []*CommitIdentity {
if c.allAuthors != nil {
return c.allAuthors
}
trailerCoAuthors := c.MessageTrailer()["co-authored-by"]
c.allAuthors = make([]*CommitIdentity, 0, 1+len(trailerCoAuthors))
exclude := map[string]int{}
addParticipant := func(name, email string, role int) (existingRole int) {
addAuthor := func(name, email string, role int) {
if name == "" && email == "" {
return 0
return
}
emailLower := strings.ToLower(email)
if existingRole = exclude[emailLower]; emailLower != "" && existingRole != 0 {
return existingRole
key := strings.ToLower(email)
if key == "" {
key = strings.ToLower(name)
}
c.allParticipants = append(c.allParticipants, &CommitIdentity{Name: name, Email: email, role: role})
exclude[emailLower] = role
return 0
if existingRole := exclude[key]; key != "" && existingRole != 0 {
return
}
c.allAuthors = append(c.allAuthors, &CommitIdentity{Name: name, Email: email, role: role})
exclude[key] = role
}
c.committerCoAuthorIdx = -1
addParticipant(c.Author.Name, c.Author.Email, commitIdentityRoleAuthor)
addParticipant(c.Committer.Name, c.Committer.Email, commitIdentityRoleCommitter)
for _, coAuthorValue := range c.MessageTrailer()["co-authored-by"] {
addAuthor(c.Author.Name, c.Author.Email, commitIdentityRoleAuthor)
for _, coAuthorValue := range trailerCoAuthors {
addr, err := mail.ParseAddress(coAuthorValue)
coAuthorName, coAuthorEmail := coAuthorValue, ""
if err == nil {
coAuthorName, coAuthorEmail = addr.Name, addr.Address
}
existingRole := addParticipant(coAuthorName, coAuthorEmail, commitIdentityRoleCoAuthor)
if existingRole == commitIdentityRoleCommitter && c.committerCoAuthorIdx == -1 {
c.committerCoAuthorIdx = len(c.allParticipants)
c.committerCoAuthor = &CommitIdentity{coAuthorName, coAuthorEmail, commitIdentityRoleCoAuthor}
}
addAuthor(coAuthorName, coAuthorEmail, commitIdentityRoleCoAuthor)
}
return c.allParticipants
return c.allAuthors
}
// CoAuthorIdentities returns co-author identities defined by "Co-authored-by:" in the git message trailer
// Only the commit's author is excluded. If committer is declared as co-author, it will be included in the result.
// * Author & Co-author: they changed the code (attribution)
// * Committer: they submitted the commit but didn't change the code (e.g.: maintainer signed a commit)
// So, a committer can also be a co-author if they changed the code.
func (c *Commit) CoAuthorIdentities() (coAuthors []*CommitIdentity) {
all := c.AllParticipantIdentities()
if len(all) <= 1 {
return nil // no co-author list
all := c.AllAuthorIdentities()
if len(all) == 0 {
return nil
}
if all[1].role != commitIdentityRoleCommitter {
return all[1:] // no committer, so all after author are co-authors
if all[0].role == commitIdentityRoleAuthor {
return all[1:]
}
if c.committerCoAuthorIdx == -1 {
return all[2:] // the committer is not in the co-author list, so just return the co-author list
}
// the committer is in the co-author list but de-duplicated, so include them as co-author again
coAuthors = append(coAuthors, all[2:c.committerCoAuthorIdx]...)
coAuthors = append(coAuthors, c.committerCoAuthor)
coAuthors = append(coAuthors, all[c.committerCoAuthorIdx:]...)
return coAuthors
return all
}
+19 -16
View File
@@ -52,41 +52,44 @@ func TestCommitMessageTrailer(t *testing.T) {
func TestCommitMessageParticipants(t *testing.T) {
sig := func(n, e string) *Signature { return &Signature{Name: n, Email: e} }
idt := func(n, e string, r int) *CommitIdentity { return &CommitIdentity{n, e, r} }
roleAuthor, roleCommitter, roleCoAuthor := commitIdentityRoleAuthor, commitIdentityRoleCommitter, commitIdentityRoleCoAuthor
roleAuthor, _, roleCoAuthor := commitIdentityRoleAuthor, commitIdentityRoleCommitter, commitIdentityRoleCoAuthor
type testCase struct {
name string
commit *Commit
identities []*CommitIdentity
}
t.Run("AllParticipants", func(t *testing.T) {
t.Run("AllAuthors", func(t *testing.T) {
cases := []testCase{
{
"DifferentUsers",
"CommitterExcluded",
&Commit{
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
CommitMessage: CommitMessage{MessageRaw: "CO-Authored-BY: x@m.com"},
CommitMessage: CommitMessage{MessageRaw: "CO-Authored-BY: Full Name <x@m.com>"},
},
[]*CommitIdentity{idt("a", "a@m.com", roleAuthor), idt("c", "c@m.com", roleCommitter), idt("", "x@m.com", roleCoAuthor)},
[]*CommitIdentity{idt("a", "a@m.com", roleAuthor), idt("Full Name", "x@m.com", roleCoAuthor)},
},
{
"SameUser",
"AuthorIsCoAuthor",
&Commit{
Author: sig("a", "a@m.com"), Committer: sig("a", "A@M.com"),
CommitMessage: CommitMessage{MessageRaw: "CO-Authored-BY: a@m.com"},
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
CommitMessage: CommitMessage{MessageRaw: "CO-Authored-BY: other-name <a@m.com>"},
},
[]*CommitIdentity{idt("a", "a@m.com", roleAuthor)},
},
{
"NoCommitter",
"EmptyAuthor", // synthesized commits (push feed) may have no author signature at all
&Commit{
Author: sig("a", "a@m.com"), Committer: sig("", ""),
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: Full Name <X@M.com>"},
Author: sig("", ""), Committer: sig("", ""),
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: c <c@m.com>"},
},
[]*CommitIdentity{idt("a", "a@m.com", roleAuthor), idt("Full Name", "X@M.com", roleCoAuthor)},
// but if the commit message contains co-authors, the co-authors are still parsed for "all authors"
// if it is a problem, the caller should fix the problem (provide correct "author")
[]*CommitIdentity{idt("c", "c@m.com", roleCoAuthor)},
},
}
for _, c := range cases {
assert.Equal(t, c.identities, c.commit.AllParticipantIdentities(), "case: %s", c.name)
assert.Equal(t, c.identities, c.commit.AllAuthorIdentities(), "case: %s", c.name)
}
})
t.Run("CoAuthors", func(t *testing.T) {
@@ -116,12 +119,12 @@ func TestCommitMessageParticipants(t *testing.T) {
[]*CommitIdentity{},
},
{
"CoAuthorCommitterNameWithIndex", // restore the committer co-author to the co-author list by the index with correct name
"CoAuthorNameOnlyAndDuplicate",
&Commit{
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: x <x@m.com>\nCo-authored-by: c-other <c@m.com>\nCo-authored-by: y <y@m.com>"},
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: b\nCo-authored-by: b\nCo-authored-by: c"},
},
[]*CommitIdentity{idt("x", "x@m.com", roleCoAuthor), idt("c-other", "c@m.com", roleCoAuthor), idt("y", "y@m.com", roleCoAuthor)},
[]*CommitIdentity{idt("b", "", roleCoAuthor), idt("c", "", roleCoAuthor)},
},
}
for _, c := range cases {
+2 -2
View File
@@ -249,7 +249,7 @@ func createRequest(ctx context.Context, method, url string, headers map[string]s
}
// performRequest sends a request, optionally performs a callback on the request and returns the response.
// If the status code is 200, the response is returned, and it will contain a non-nil Body.
// If the status code is in the 2xx range, the response is returned, and it will contain a non-nil Body.
// Otherwise, it will return an error, and the Body will be nil or closed.
func performRequest(ctx context.Context, client *http.Client, req *http.Request) (*http.Response, error) {
log.Trace("performRequest: %s", req.URL)
@@ -264,7 +264,7 @@ func performRequest(ctx context.Context, client *http.Client, req *http.Request)
return res, err
}
if res.StatusCode != http.StatusOK {
if res.StatusCode < 200 || res.StatusCode >= 300 {
defer res.Body.Close()
return res, handleErrorResponse(res)
}
+9
View File
@@ -135,6 +135,15 @@ func TestBasicTransferAdapter(t *testing.T) {
}
})
t.Run("Upload created", func(t *testing.T) {
client := &http.Client{Transport: RoundTripFunc(func(req *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusCreated, Body: io.NopCloser(strings.NewReader(""))}
})}
adapter := &BasicTransferAdapter{client: client}
err := adapter.Upload(t.Context(), &Link{Href: "https://upload-created-request.io"}, p, strings.NewReader("dummy"))
assert.NoError(t, err)
})
t.Run("Verify", func(t *testing.T) {
cases := []struct {
link *Link
+33 -22
View File
@@ -5,11 +5,11 @@ package external
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"os/exec"
"runtime"
"strings"
"gitea.dev/modules/markup"
@@ -91,52 +91,63 @@ func (p *Renderer) GetExternalRendererOptions() (ret markup.ExternalRendererOpti
return ret
}
func envMark(envName string) string {
if runtime.GOOS == "windows" {
return "%" + envName + "%"
func (p *Renderer) prepareExternalCommand(vars map[string]string) (string, []string, error) {
fields, err := shellquote.Split(strings.TrimSpace(p.Command))
if err != nil {
return "", nil, err
}
return "$" + envName
if len(fields) == 0 {
return "", nil, errors.New("no command")
}
var replacements []string
for k, v := range vars {
replacements = append(replacements, "$"+k, v)
replacements = append(replacements, "%"+k+"%", v) // for legacy Windows-style support
}
r := strings.NewReplacer(replacements...)
for i := range fields {
fields[i] = r.Replace(fields[i])
}
return fields[0], fields[1:], nil
}
// Render renders the data of the document to HTML via the external tool.
func (p *Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
baseLinkSrc := ctx.RenderHelper.ResolveLink("", markup.LinkTypeDefault)
baseLinkRaw := ctx.RenderHelper.ResolveLink("", markup.LinkTypeRaw)
command := strings.NewReplacer(
envMark("GITEA_PREFIX_SRC"), baseLinkSrc,
envMark("GITEA_PREFIX_RAW"), baseLinkRaw,
).Replace(p.Command)
commands, err := shellquote.Split(command)
if err != nil || len(commands) == 0 {
return fmt.Errorf("%s invalid command %q: %w", p.Name(), p.Command, err)
cmdVars := map[string]string{
"GITEA_PREFIX_SRC": baseLinkSrc,
"GITEA_PREFIX_RAW": baseLinkRaw,
}
cmdProg, cmdArgs, err := p.prepareExternalCommand(cmdVars)
if err != nil {
return fmt.Errorf("invalid external render (%s) command %q: %w", p.Name(), p.Command, err)
}
args := commands[1:]
if p.IsInputFile {
// write to temp file
f, cleanup, err := setting.AppDataTempDir("git-repo-content").CreateTempFileRandom("gitea_input")
tmpFile, cleanup, err := setting.AppDataTempDir("git-repo-content").CreateTempFileRandom("gitea_input")
if err != nil {
return fmt.Errorf("%s create temp file when rendering %s failed: %w", p.Name(), p.Command, err)
}
defer cleanup()
_, err = io.Copy(f, input)
_, err = io.Copy(tmpFile, input)
if err != nil {
_ = f.Close()
_ = tmpFile.Close()
return fmt.Errorf("%s write data to temp file when rendering %s failed: %w", p.Name(), p.Command, err)
}
err = f.Close()
err = tmpFile.Close()
if err != nil {
return fmt.Errorf("%s close temp file when rendering %s failed: %w", p.Name(), p.Command, err)
}
args = append(args, f.Name())
cmdArgs = append(cmdArgs, tmpFile.Name())
}
processCtx, _, finished := process.GetManager().AddContext(ctx, fmt.Sprintf("Render [%s] for %s", commands[0], baseLinkSrc))
processCtx, _, finished := process.GetManager().AddContext(ctx, fmt.Sprintf("Render [%s] for %s", cmdProg, baseLinkSrc))
defer finished()
cmd := exec.CommandContext(processCtx, commands[0], args...)
cmd := exec.CommandContext(processCtx, cmdProg, cmdArgs...)
cmd.Env = append(
os.Environ(),
"GITEA_PREFIX_SRC="+baseLinkSrc,
@@ -151,7 +162,7 @@ func (p *Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.
process.SetSysProcAttribute(cmd)
if err := cmd.Run(); err != nil {
return fmt.Errorf("%s render run command %s %v failed: %w\nStderr: %s", p.Name(), commands[0], args, err, stderr.String())
return fmt.Errorf("%s render run command %s %v failed: %w\nStderr: %s", p.Name(), cmdProg, shellquote.Join(cmdArgs...), err, stderr.String())
}
return nil
}
+24
View File
@@ -0,0 +1,24 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package external
import (
"testing"
"gitea.dev/modules/setting"
"github.com/stretchr/testify/assert"
)
func TestPrepareExternalCommand(t *testing.T) {
r := &Renderer{MarkupRenderer: &setting.MarkupRenderer{Command: ""}}
_, _, err := r.prepareExternalCommand(map[string]string{"KEY": "val"})
assert.ErrorContains(t, err, "no command")
r = &Renderer{MarkupRenderer: &setting.MarkupRenderer{Command: `"/foo bar/bin" --opt $KEY "$KEY" %KEY% other`}}
prog, args, err := r.prepareExternalCommand(map[string]string{"KEY": `a"b`})
assert.NoError(t, err)
assert.Equal(t, "/foo bar/bin", prog)
assert.Equal(t, []string{"--opt", `a"b`, `a"b`, `a"b`, "other"}, args)
}
+1 -8
View File
@@ -21,19 +21,12 @@ type frontendRenderer struct {
patterns []string
}
var (
_ markup.PostProcessRenderer = (*frontendRenderer)(nil)
_ markup.ExternalRenderer = (*frontendRenderer)(nil)
)
var _ markup.ExternalRenderer = (*frontendRenderer)(nil)
func (p *frontendRenderer) Name() string {
return p.name
}
func (p *frontendRenderer) NeedPostProcess() bool {
return false
}
func (p *frontendRenderer) FileNamePatterns() []string {
// TODO: the file extensions are ambiguous, even if the file name matches, it doesn't mean that the file is a 3D model
// There are some approaches to make it more accurate, but they are all complicated:
+3 -6
View File
@@ -29,9 +29,8 @@ func init() {
type renderer struct{}
var (
_ markup.Renderer = (*renderer)(nil)
_ markup.PostProcessRenderer = (*renderer)(nil)
_ markup.ExternalRenderer = (*renderer)(nil) // FIXME: this is not an external render, need to refactor the framework in the future
_ markup.Renderer = (*renderer)(nil)
_ markup.ExternalRenderer = (*renderer)(nil) // FIXME: this is not an external render, need to refactor the framework in the future
)
type mimeHandler struct {
@@ -96,8 +95,6 @@ func (renderer) Name() string {
return "jupyter-render"
}
func (renderer) NeedPostProcess() bool { return true }
func (renderer) GetExternalRendererOptions() markup.ExternalRendererOptions {
return markup.ExternalRendererOptions{
// HINT: no need to let markup render sanitize the output because there are many special CSS class names, inline attributes.
@@ -215,7 +212,7 @@ func renderCellCode(output htmlutil.HTMLWriter, cell Cell, language string) erro
// Highlight code
lexer := highlight.DetectChromaLexerByFileName("", language)
output.WriteFormat(`<div class="cell-right cell-input"><pre><code class="chroma language-%s">`, strings.ToLower(language))
output.WriteFormat(`<div class="cell-right cell-input"><pre><code class="chroma language-%s">`, strings.ToLower(lexer.Config().Name))
output.WriteHTML(highlight.RenderCodeByLexer(lexer, source))
output.WriteHTML("</code></pre></div>")
}
+5 -5
View File
@@ -261,7 +261,7 @@ func TestIntegrationAndSanitization(t *testing.T) {
maliciousNotebook := `{
"nbformat": 4,
"nbformat_minor": 2,
"metadata": {},
"metadata": {"language_info":{"name":"any lang"}},
"cells": [
{
"cell_type": "code",
@@ -274,7 +274,7 @@ func TestIntegrationAndSanitization(t *testing.T) {
"execution_count": 1,
"data": {
"text/html": [
"<div><script>alert('XSS Vector')</script><table class=\"dataframe\"><tr><td>Safe Content</td></tr></table></div>"
"<div><script>foo</script><table class=other><tr><td>[[name=no-post-process|link=/link]]</td></tr></table></div>"
]
},
"metadata": {}
@@ -295,8 +295,8 @@ func TestIntegrationAndSanitization(t *testing.T) {
<div class="cell-line">
<div class="cell-left cell-prompt">In [1]:</div>
<div class="cell-right cell-input">
<pre><code class="chroma language-python">
<span class="n">a</span><span class="o">=</span><span class="mi">1</span>
<pre><code class="chroma language-fallback">
a=1
</code></pre>
</div>
</div>
@@ -304,7 +304,7 @@ func TestIntegrationAndSanitization(t *testing.T) {
<div class="cell-left cell-prompt">Out [1]:</div>
<div class="cell-right cell-output">
<div class="cell-output-html">
<div><table><tbody><tr><td>Safe Content</td></tr></tbody></table></div>
<div><table><tr><td>[[name=no-post-process|link=/link]]</td></tr></table></div>
</div>
</div>
</div>
-1
View File
@@ -14,7 +14,6 @@ import (
func TestMain(m *testing.M) {
setting.IsInTesting = true
markup.RenderBehaviorForTesting.DisableAdditionalAttributes = true
setting.Markdown.FileNamePatterns = []string{"*.md"}
markup.RefreshFileNamePatterns()
os.Exit(m.Run())
}
+3
View File
@@ -153,6 +153,9 @@ func ParsePackage(r io.Reader) (*Package, error) {
return nil, err
}
} else if !strings.HasPrefix(filename, ".") {
if strings.ContainsAny(hd.Name, "\n\r") {
continue // a newline would forge extra lines in the pacman index
}
if err := files.Add(hd.Name); err != nil {
return nil, err
}
+1
View File
@@ -104,6 +104,7 @@ func TestParsePackage(t *testing.T) {
data := createPackage(c, map[string][]byte{
".PKGINFO": createPKGINFOContent(packageName, packageVersion),
"/test/dummy.txt": {},
"usr/lib/legit\n\n%FILES%\n/etc/cron.d/x": {}, // must not reach the file list
})
p, err := ParsePackage(data)
+17 -2
View File
@@ -123,11 +123,26 @@ func ParsePackage(sr io.ReaderAt, size int64, mr io.Reader) (*Package, error) {
},
}
// Nested packages (test fixtures, examples, benchmarks) ship their own manifests, which must not
// replace the package manifest. The package sits at the archive root or in a single top level
// directory, so keep only the shallowest manifest directory, breaking ties by name for stability.
var manifestFiles []*zip.File
manifestDir, manifestDepth := "", 0
for _, file := range zr.File {
manifestMatch := manifestPattern.FindStringSubmatch(path.Base(file.Name))
if len(manifestMatch) == 0 {
if strings.HasSuffix(file.Name, "/") || !manifestPattern.MatchString(path.Base(file.Name)) {
continue
}
dir, depth := path.Dir(file.Name), strings.Count(file.Name, "/")
switch {
case manifestFiles == nil || depth < manifestDepth || (depth == manifestDepth && dir < manifestDir):
manifestDir, manifestDepth, manifestFiles = dir, depth, []*zip.File{file}
case dir == manifestDir:
manifestFiles = append(manifestFiles, file)
}
}
for _, file := range manifestFiles {
manifestMatch := manifestPattern.FindStringSubmatch(path.Base(file.Name))
if file.UncompressedSize64 > maxManifestFileSize {
return nil, ErrManifestFileTooLarge
+84
View File
@@ -4,6 +4,7 @@
package swift
import (
"archive/zip"
"bytes"
"strings"
"testing"
@@ -24,6 +25,18 @@ const (
packageLicense = "MIT"
)
// writeOrderedZipArchive writes name/content pairs in the given order, which map based test.WriteZipArchive cannot do
func writeOrderedZipArchive(entries [][2]string) *bytes.Buffer {
buf := &bytes.Buffer{}
zw := zip.NewWriter(buf)
for _, entry := range entries {
w, _ := zw.Create(entry[0])
_, _ = w.Write([]byte(entry[1]))
}
_ = zw.Close()
return buf
}
func TestParsePackage(t *testing.T) {
t.Run("MissingManifestFile", func(t *testing.T) {
data := test.WriteZipArchive(map[string]string{"dummy.txt": ""})
@@ -65,6 +78,77 @@ func TestParsePackage(t *testing.T) {
assert.Equal(t, content2, m.Content)
})
t.Run("IgnoresNestedManifests", func(t *testing.T) {
rootManifest := "// swift-tools-version:5.7\n//\n// Package.swift"
rootAltManifest := "// swift-tools-version:5.5\n//\n// Package@swift-5.5.swift"
rootPatchAltManifest := "// swift-tools-version:5.7.1\n//\n// Package@swift-5.7.1.swift"
nestedManifest := "// swift-tools-version:6.3\n//\n// nested fixture package"
data := writeOrderedZipArchive([][2]string{
{"Package.swift", rootManifest},
{"Package@swift-5.5.swift", rootAltManifest},
{"Package@swift-5.7.1.swift", rootPatchAltManifest},
{"Benchmarks/Package.swift", nestedManifest},
{"Utils/Fixtures/PlainPackage/Package.swift", nestedManifest},
})
p, err := ParsePackage(bytes.NewReader(data.Bytes()), int64(data.Len()), nil)
assert.NotNil(t, p)
assert.NoError(t, err)
assert.Len(t, p.Metadata.Manifests, 3)
assert.Equal(t, rootManifest, p.Metadata.Manifests[""].Content)
assert.Equal(t, "5.7", p.Metadata.Manifests[""].ToolsVersion)
assert.Equal(t, rootAltManifest, p.Metadata.Manifests["5.5"].Content)
assert.Equal(t, rootPatchAltManifest, p.Metadata.Manifests["5.7.1"].Content)
})
t.Run("IgnoresNestedManifestsInPrefixedArchive", func(t *testing.T) {
rootManifest := "// swift-tools-version:5.7\n//\n// Package.swift"
// `swift package archive-source` produces archives with a single top level directory
data := writeOrderedZipArchive([][2]string{
{"gitea-1.0.1/Package.swift", rootManifest},
{"gitea-1.0.1/Tests/Fixtures/Package.swift", "// swift-tools-version:6.3"},
})
p, err := ParsePackage(bytes.NewReader(data.Bytes()), int64(data.Len()), nil)
assert.NotNil(t, p)
assert.NoError(t, err)
assert.Len(t, p.Metadata.Manifests, 1)
assert.Equal(t, rootManifest, p.Metadata.Manifests[""].Content)
})
t.Run("AltManifestOnlyInRootDirectory", func(t *testing.T) {
// a deeper Package.swift belongs to a nested package and must not stand in for the missing root manifest
data := test.WriteZipArchive(map[string]string{
"Package@swift-5.5.swift": "// swift-tools-version:5.5",
"Sub/Package.swift": "// swift-tools-version:5.7",
})
p, err := ParsePackage(bytes.NewReader(data.Bytes()), int64(data.Len()), nil)
assert.Nil(t, p)
assert.ErrorIs(t, err, ErrMissingManifestFile)
})
t.Run("ManifestDirectoryTieBreak", func(t *testing.T) {
contentA := "// swift-tools-version:5.7\n// A"
contentB := "// swift-tools-version:5.7\n// B"
// at equal depth the name decides, never the archive order
data := writeOrderedZipArchive([][2]string{
{"a/Package.swift", contentA},
{"b/Package.swift", contentB},
})
p, err := ParsePackage(bytes.NewReader(data.Bytes()), int64(data.Len()), nil)
assert.NotNil(t, p)
assert.NoError(t, err)
assert.Len(t, p.Metadata.Manifests, 1)
assert.Equal(t, contentA, p.Metadata.Manifests[""].Content)
})
t.Run("WithMetadata", func(t *testing.T) {
data := test.WriteZipArchive(map[string]string{
"Package.swift": "// swift-tools-version:5.7\n//\n// Package.swift",
+2 -1
View File
@@ -50,7 +50,8 @@ var Markdown = struct {
MathCodeBlockDetection []string
MathCodeBlockOptions MarkdownMathCodeBlockOptions `ini:"-"`
}{
EnableMath: true,
EnableMath: true,
FileNamePatterns: []string{"*.md"},
}
// MarkupRenderer defines the external parser configured in ini
+6 -8
View File
@@ -304,12 +304,10 @@ func (a *AzureBlobStorage) ServeDirectURL(storePath, name, method string, reqPar
// IterateObjects iterates across the objects in the azureblobstorage
func (a *AzureBlobStorage) IterateObjects(dirName string, fn func(path string, obj Object) error) error {
dirName = a.buildAzureBlobPath(dirName)
if dirName != "" {
dirName += "/"
}
basePrefix := buildObjectStorePathPrefix(a.cfg.BasePath, "")
dirPrefix := buildObjectStorePathPrefix(a.cfg.BasePath, dirName)
pager := a.client.NewListBlobsFlatPager(a.cfg.Container, &container.ListBlobsFlatOptions{
Prefix: &dirName,
Prefix: &dirPrefix,
})
for pager.More() {
resp, err := pager.NextPage(a.ctx)
@@ -317,7 +315,8 @@ func (a *AzureBlobStorage) IterateObjects(dirName string, fn func(path string, o
return convertAzureBlobErr(err)
}
for _, object := range resp.Segment.BlobItems {
blobClient := a.getBlobClient(*object.Name)
objPath := strings.TrimPrefix(*object.Name, basePrefix)
blobClient := a.getBlobClient(objPath)
object := &azureBlobObject{
Context: a.ctx,
blobClient: blobClient,
@@ -327,7 +326,7 @@ func (a *AzureBlobStorage) IterateObjects(dirName string, fn func(path string, o
}
if err := func(object *azureBlobObject, fn func(path string, obj Object) error) error {
defer object.Close()
return fn(strings.TrimPrefix(object.Name, a.cfg.BasePath), object)
return fn(objPath, object)
}(object, fn); err != nil {
return convertAzureBlobErr(err)
}
@@ -336,7 +335,6 @@ func (a *AzureBlobStorage) IterateObjects(dirName string, fn func(path string, o
return nil
}
// Delete delete a file
func (a *AzureBlobStorage) getBlobClient(path string) *blob.Client {
return a.client.ServiceClient().NewContainerClient(a.cfg.Container).NewBlobClient(a.buildAzureBlobPath(path))
}
+10 -18
View File
@@ -27,24 +27,16 @@ func TestAzureBlobStorage(t *testing.T) {
Container: "test",
},
}
table := []struct {
name string
test func(t *testing.T, typStr Type, cfg *setting.Storage)
}{
{
name: "iterator",
test: testStorageIterator,
},
{
name: "testBlobStorageURLContentTypeAndDisposition",
test: testBlobStorageURLContentTypeAndDisposition,
},
}
for _, entry := range table {
t.Run(entry.name, func(t *testing.T) {
entry.test(t, storageType, config)
})
}
t.Run("Iterator", func(t *testing.T) {
testStorageIterator(t, storageType, config)
})
t.Run("BlobStorageURLContentTypeAndDisposition", func(t *testing.T) {
testBlobStorageURLContentTypeAndDisposition(t, storageType, config)
})
t.Run("IteratorWithBasePath", func(t *testing.T) {
config.AzureBlobConfig.BasePath = "test-base-path"
testStorageIterator(t, storageType, config)
})
}
func TestAzureBlobStoragePath(t *testing.T) {
+6
View File
@@ -26,6 +26,8 @@ import (
var _ ObjectStorage = &MinioStorage{}
const unknownSizePartSize = 1024 * 1024 * 16 // same as minio-go's minPartSize
type minioObject struct {
*minio.Object
}
@@ -211,6 +213,10 @@ func (m *MinioStorage) Save(path string, r io.Reader, size int64) (int64, error)
// * https://www.backblaze.com/b2/docs/s3_compatible_api.html
// do not support "x-amz-checksum-algorithm" header, so use legacy MD5 checksum
SendContentMd5: m.cfg.ChecksumAlgorithm == "md5",
// with an unknown size (-1) minio-go assumes a 5TiB object and buffers a 528MiB part for it, even
// for a payload of a few KiB, so pin the part size there, a known size derives its own
PartSize: util.Iif[uint64](size < 0, unknownSizePartSize, 0),
},
)
if err != nil {
+19
View File
@@ -11,11 +11,13 @@ import (
"net/url"
"os"
"path"
"strings"
"gitea.dev/modules/httplib"
"gitea.dev/modules/log"
"gitea.dev/modules/public"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
)
// ErrURLNotSupported represents url is not supported
@@ -139,6 +141,23 @@ func SaveFrom(objStorage ObjectStorage, path string, callback func(w io.Writer)
return err
}
func buildObjectStorePath(base, p string) string {
p = strings.TrimPrefix(util.PathJoinRelX(base, p), "/") // object store doesn't use slash for root path
if p == "." {
p = "" // object store doesn't use dot as relative path
}
return p
}
func buildObjectStorePathPrefix(base, p string) string {
// ending slash is required for avoiding matching like "foo/" and "foobar/" with prefix "foo"
p = buildObjectStorePath(base, p) + "/"
if p == "/" {
p = "" // object store doesn't use slash for root path
}
return p
}
var (
// Attachments represents attachments storage
Attachments ObjectStorage = uninitializedStorage
+9 -1
View File
@@ -4,6 +4,7 @@
package storage
import (
"io"
"net/http"
"strings"
"testing"
@@ -31,6 +32,11 @@ func testStorageIterator(t *testing.T, typStr Type, cfg *setting.Storage) {
_, err = l.Save(f[0], strings.NewReader(f[1]), -1)
assert.NoError(t, err)
}
defer func() {
for _, f := range testFiles {
_ = l.Delete(f[0])
}
}()
expectedList := map[string][]string{
"a": {"a/1.txt"},
@@ -43,7 +49,9 @@ func testStorageIterator(t *testing.T, typStr Type, cfg *setting.Storage) {
for dir, expected := range expectedList {
count := 0
err = l.IterateObjects(dir, func(path string, f Object) error {
defer f.Close()
content, err := io.ReadAll(f)
assert.NoError(t, err)
assert.NotEmpty(t, content)
assert.Contains(t, expected, path)
count++
return nil
+1 -1
View File
@@ -370,7 +370,7 @@ func (ut *RenderUtils) AvatarStackPushCommit(pushCommit *repository.PushCommit)
// there is no way to know the real committer, but the field can't be nil
Committer: &git.Signature{Name: pushCommit.AuthorName, Email: pushCommit.AuthorEmail},
}
data := user_model.BuildAvatarStackData(ut.ctx, fakeGitCommit.AllParticipantIdentities(), nil)
data := user_model.BuildAvatarStackData(ut.ctx, fakeGitCommit.AllAuthorIdentities(), nil)
return ut.AvatarStack(data)
}
+1 -1
View File
@@ -52,7 +52,7 @@
"jquery": "4.0.0",
"js-yaml": "4.2.0",
"katex": "0.17.0",
"mermaid": "11.15.0",
"mermaid": "11.16.1",
"online-3d-viewer": "0.18.0",
"pdfobject": "2.3.1",
"perfect-debounce": "2.1.0",
+12 -12
View File
@@ -73,7 +73,7 @@ importers:
version: 0.1.0-rc2
'@mermaid-js/layout-elk':
specifier: 0.2.1
version: 0.2.1(mermaid@11.15.0)
version: 0.2.1(mermaid@11.16.1)
'@primer/octicons':
specifier: 19.28.1
version: 19.28.1
@@ -147,8 +147,8 @@ importers:
specifier: 0.17.0
version: 0.17.0
mermaid:
specifier: 11.15.0
version: 11.15.0
specifier: 11.16.1
version: 11.16.1
online-3d-viewer:
specifier: 0.18.0
version: 0.18.0
@@ -942,8 +942,8 @@ packages:
peerDependencies:
mermaid: ^11.0.2
'@mermaid-js/parser@1.1.1':
resolution: {integrity: sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==}
'@mermaid-js/parser@1.2.0':
resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==}
'@napi-rs/wasm-runtime@1.1.6':
resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
@@ -3301,8 +3301,8 @@ packages:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
mermaid@11.15.0:
resolution: {integrity: sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==}
mermaid@11.16.1:
resolution: {integrity: sha512-TQsq6u22fAn3rek5VOubrhKPo1g5hwC3FXUN9hiyupTckcYiGuuKGkNQrKYwGJkXUxZdojwRG46gsSCFZMDp4g==}
micromark-core-commonmark@2.0.3:
resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
@@ -5179,13 +5179,13 @@ snapshots:
- supports-color
- utf-8-validate
'@mermaid-js/layout-elk@0.2.1(mermaid@11.15.0)':
'@mermaid-js/layout-elk@0.2.1(mermaid@11.16.1)':
dependencies:
d3: 7.9.0
elkjs: 0.9.3
mermaid: 11.15.0
mermaid: 11.16.1
'@mermaid-js/parser@1.1.1':
'@mermaid-js/parser@1.2.0':
dependencies:
'@chevrotain/types': 11.1.2
@@ -7764,11 +7764,11 @@ snapshots:
merge2@1.4.1: {}
mermaid@11.15.0:
mermaid@11.16.1:
dependencies:
'@braintree/sanitize-url': 7.1.2
'@iconify/utils': 3.1.3
'@mermaid-js/parser': 1.1.1
'@mermaid-js/parser': 1.2.0
'@types/d3': 7.4.3
'@upsetjs/venn.js': 2.0.0
cytoscape: 3.33.4
+33 -17
View File
@@ -66,6 +66,7 @@ import (
"errors"
"fmt"
"net/http"
"slices"
"strconv"
"strings"
@@ -74,7 +75,6 @@ import (
"gitea.dev/modules/httplib"
"gitea.dev/modules/json"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/modules/storage"
"gitea.dev/modules/util"
@@ -336,15 +336,19 @@ type (
)
func (ar artifactRoutes) listArtifacts(ctx *ArtifactContext) {
_, runID, ok := validateRunID(ctx)
task, runID, ok := validateRunID(ctx)
if !ok {
return
}
attemptIDs, ok := readableArtifactAttemptIDs(ctx, task)
if !ok {
return
}
artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{
RunID: runID,
RunAttemptID: optional.Some(ctx.ActionTask.Job.RunAttemptID),
Status: int(actions.ArtifactStatusUploadConfirmed),
artifacts, err := actions.FindReadableArtifacts(ctx, actions.FindArtifactsOptions{
RunID: runID,
RunAttemptIDs: attemptIDs,
Status: int(actions.ArtifactStatusUploadConfirmed),
})
if err != nil {
log.Error("Error getting artifacts: %v", err)
@@ -397,7 +401,7 @@ type (
// getDownloadArtifactURL generates download url for each artifact
func (ar artifactRoutes) getDownloadArtifactURL(ctx *ArtifactContext) {
_, runID, ok := validateRunID(ctx)
task, runID, ok := validateRunID(ctx)
if !ok {
return
}
@@ -407,11 +411,16 @@ func (ar artifactRoutes) getDownloadArtifactURL(ctx *ArtifactContext) {
return
}
artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{
RunID: runID,
RunAttemptID: optional.Some(ctx.ActionTask.Job.RunAttemptID),
ArtifactName: itemPath,
Status: int(actions.ArtifactStatusUploadConfirmed),
attemptIDs, ok := readableArtifactAttemptIDs(ctx, task)
if !ok {
return
}
artifacts, err := actions.FindReadableArtifacts(ctx, actions.FindArtifactsOptions{
RunID: runID,
RunAttemptIDs: attemptIDs,
ArtifactName: itemPath,
Status: int(actions.ArtifactStatusUploadConfirmed),
})
if err != nil {
log.Error("Error getting artifacts: %v", err)
@@ -461,7 +470,7 @@ func (ar artifactRoutes) getDownloadArtifactURL(ctx *ArtifactContext) {
// downloadArtifact downloads artifact content
func (ar artifactRoutes) downloadArtifact(ctx *ArtifactContext) {
_, runID, ok := validateRunID(ctx)
task, runID, ok := validateRunID(ctx)
if !ok {
return
}
@@ -483,10 +492,17 @@ func (ar artifactRoutes) downloadArtifact(ctx *ArtifactContext) {
ctx.HTTPError(http.StatusBadRequest)
return
}
if ctx.ActionTask.Job.RunAttemptID > 0 && artifact.RunAttemptID != ctx.ActionTask.Job.RunAttemptID {
log.Error("Error mismatch runAttemptID and artifactID, task: %v, artifact: %v", ctx.ActionTask.Job.RunAttemptID, artifactID)
ctx.HTTPError(http.StatusBadRequest)
return
// resolving the readable attempts costs a query, and an artifact of the task's own attempt never needs it
if artifact.RunAttemptID != task.Job.RunAttemptID {
attemptIDs, ok := readableArtifactAttemptIDs(ctx, task)
if !ok {
return
}
if !slices.Contains(attemptIDs, artifact.RunAttemptID) {
log.Error("Error artifact %d belongs to run attempt %d, which the task cannot read: %v", artifactID, artifact.RunAttemptID, attemptIDs)
ctx.HTTPError(http.StatusBadRequest)
return
}
}
if artifact.Status != actions.ArtifactStatusUploadConfirmed {
log.Error("Error artifact not found: %s", artifact.Status.ToString())
+3 -4
View File
@@ -20,7 +20,6 @@ import (
"gitea.dev/models/actions"
"gitea.dev/models/db"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/modules/storage"
)
@@ -261,9 +260,9 @@ func listOrderedChunksForArtifact(st storage.ObjectStorage, runID, artifactID in
func mergeChunksForRun(ctx *ArtifactContext, st storage.ObjectStorage, runID, runAttemptID int64, artifactName string) error {
// read all db artifacts by name
artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{
RunID: runID,
RunAttemptID: optional.Some(runAttemptID),
ArtifactName: artifactName,
RunID: runID,
RunAttemptIDs: []int64{runAttemptID},
ArtifactName: artifactName,
})
if err != nil {
return err
+13 -1
View File
@@ -43,7 +43,7 @@ func validateRunID(ctx *ArtifactContext) (*actions.ActionTask, int64, bool) {
return task, runID, true
}
func validateRunIDV4(ctx *ArtifactContext, rawRunID string) (*actions.ActionTask, int64, bool) { //nolint:unparam // ActionTask is never used
func validateRunIDV4(ctx *ArtifactContext, rawRunID string) (*actions.ActionTask, int64, bool) {
task := ctx.ActionTask
runID, err := strconv.ParseInt(rawRunID, 10, 64)
if err != nil || task.Job.RunID != runID {
@@ -54,6 +54,18 @@ func validateRunIDV4(ctx *ArtifactContext, rawRunID string) (*actions.ActionTask
return task, runID, true
}
// readableArtifactAttemptIDs resolves the attempts a task may read artifacts from:
// its own attempt, plus the attempts it inherits from when only a subset of the run's jobs was re-run.
func readableArtifactAttemptIDs(ctx *ArtifactContext, task *actions.ActionTask) ([]int64, bool) {
attemptIDs, err := actions.GetArtifactAttemptIDs(ctx, task.Job)
if err != nil {
log.Error("Error getting readable artifact attempts: %v", err)
ctx.HTTPError(http.StatusInternalServerError, "Error getting readable artifact attempts")
return nil, false
}
return attemptIDs, true
}
func validateArtifactHash(ctx *ArtifactContext, artifactName string) bool {
paramHash := ctx.PathParam("artifact_hash")
// use artifact name to create upload url
+46 -24
View File
@@ -107,7 +107,6 @@ import (
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/httplib"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/modules/storage"
"gitea.dev/modules/util"
@@ -262,9 +261,28 @@ func (r *artifactV4Routes) verifySignature(ctx *ArtifactContext, endp string) (*
return task, artifactName, true
}
func (r *artifactV4Routes) getArtifactByName(ctx *ArtifactContext, runID, runAttemptID int64, name string) (*actions_model.ActionArtifact, error) {
// getOwnAttemptArtifactByName resolves an artifact of the given attempt whatever its status,
// since upload and finalize work on the pending row they just created.
func (r *artifactV4Routes) getOwnAttemptArtifactByName(ctx *ArtifactContext, runID, runAttemptID int64, name string) (*actions_model.ActionArtifact, error) {
return r.findArtifactByName(ctx, runID, []int64{runAttemptID}, name, nil)
}
// getDownloadableArtifactByName resolves the newest artifact with the given name within the attempts whose content can still be served,
// so a pending, deleted or expired row of a newer attempt does not shadow the confirmed copy inherited from an older one.
func (r *artifactV4Routes) getDownloadableArtifactByName(ctx *ArtifactContext, runID int64, runAttemptIDs []int64, name string) (*actions_model.ActionArtifact, error) {
return r.findArtifactByName(ctx, runID, runAttemptIDs, name, builder.Eq{"status": actions_model.ArtifactStatusUploadConfirmed})
}
func (r *artifactV4Routes) findArtifactByName(ctx *ArtifactContext, runID int64, runAttemptIDs []int64, name string, extraCond builder.Cond) (*actions_model.ActionArtifact, error) {
cond := builder.NewCond().
And(builder.Eq{"run_id": runID, "artifact_name": name}, builder.Like{"content_encoding", "%/%"}).
And(builder.In("run_attempt_id", runAttemptIDs))
if extraCond != nil {
cond = cond.And(extraCond)
}
var art actions_model.ActionArtifact
has, err := db.GetEngine(ctx).Where(builder.Eq{"run_id": runID, "run_attempt_id": runAttemptID, "artifact_name": name}, builder.Like{"content_encoding", "%/%"}).Get(&art)
has, err := db.GetEngine(ctx).Where(cond).OrderBy("run_attempt_id DESC, id DESC").Get(&art)
if err != nil {
return nil, err
} else if !has {
@@ -384,7 +402,7 @@ func (r *artifactV4Routes) uploadArtifact(ctx *ArtifactContext) {
switch comp {
case "block", "appendBlock":
// get artifact by name
artifact, err := r.getArtifactByName(ctx, task.Job.RunID, task.Job.RunAttemptID, artifactName)
artifact, err := r.getOwnAttemptArtifactByName(ctx, task.Job.RunID, task.Job.RunAttemptID, artifactName)
if err != nil {
log.Error("Error artifact not found: %v", err)
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
@@ -471,7 +489,7 @@ func (r *artifactV4Routes) finalizeArtifact(ctx *ArtifactContext) {
}
// get artifact by name
artifact, err := r.getArtifactByName(ctx, runID, ctx.ActionTask.Job.RunAttemptID, req.Name)
artifact, err := r.getOwnAttemptArtifactByName(ctx, runID, ctx.ActionTask.Job.RunAttemptID, req.Name)
if err != nil {
log.Error("Error artifact not found: %v", err)
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
@@ -578,14 +596,18 @@ func (r *artifactV4Routes) listArtifacts(ctx *ArtifactContext) {
if ok := r.parseProtobufBody(ctx, &req); !ok {
return
}
_, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
task, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
if !ok {
return
}
attemptIDs, ok := readableArtifactAttemptIDs(ctx, task)
if !ok {
return
}
artifacts, err := db.Find[actions_model.ActionArtifact](ctx, actions_model.FindArtifactsOptions{
artifacts, err := actions_model.FindReadableArtifacts(ctx, actions_model.FindArtifactsOptions{
RunID: runID,
RunAttemptID: optional.Some(ctx.ActionTask.Job.RunAttemptID),
RunAttemptIDs: attemptIDs,
Status: int(actions_model.ArtifactStatusUploadConfirmed),
FinalizedArtifactsV4: true,
})
@@ -597,6 +619,8 @@ func (r *artifactV4Routes) listArtifacts(ctx *ArtifactContext) {
list := []*ListArtifactsResponse_MonolithArtifact{}
// both filters pick from what this attempt may read, so they run after the shadowed artifacts are gone:
// a shadowed artifact is not downloadable either, GetSignedArtifactURL resolves by name
table := map[string]*ListArtifactsResponse_MonolithArtifact{}
for _, artifact := range artifacts {
if _, ok := table[artifact.ArtifactName]; ok || req.IdFilter != nil && artifact.ID != req.IdFilter.Value || req.NameFilter != nil && artifact.ArtifactName != req.NameFilter.Value {
@@ -631,7 +655,11 @@ func (r *artifactV4Routes) getSignedArtifactURL(ctx *ArtifactContext) {
if ok := r.parseProtobufBody(ctx, &req); !ok {
return
}
_, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
task, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
if !ok {
return
}
attemptIDs, ok := readableArtifactAttemptIDs(ctx, task)
if !ok {
return
}
@@ -639,17 +667,12 @@ func (r *artifactV4Routes) getSignedArtifactURL(ctx *ArtifactContext) {
artifactName := req.Name
// get artifact by name
artifact, err := r.getArtifactByName(ctx, runID, ctx.ActionTask.Job.RunAttemptID, artifactName)
artifact, err := r.getDownloadableArtifactByName(ctx, runID, attemptIDs, artifactName)
if err != nil {
log.Error("Error artifact not found: %v", err)
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
return
}
if artifact.Status != actions_model.ArtifactStatusUploadConfirmed {
log.Error("Error artifact not found: %s", artifact.Status.ToString())
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
return
}
respData := GetSignedArtifactURLResponse{}
@@ -671,16 +694,15 @@ func (r *artifactV4Routes) downloadArtifact(ctx *ArtifactContext) {
if !ok {
return
}
// get artifact by name
artifact, err := r.getArtifactByName(ctx, task.Job.RunID, task.Job.RunAttemptID, artifactName)
if err != nil {
log.Error("Error artifact not found: %v", err)
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
attemptIDs, ok := readableArtifactAttemptIDs(ctx, task)
if !ok {
return
}
if artifact.Status != actions_model.ArtifactStatusUploadConfirmed {
log.Error("Error artifact not found: %s", artifact.Status.ToString())
// get artifact by name
artifact, err := r.getDownloadableArtifactByName(ctx, task.Job.RunID, attemptIDs, artifactName)
if err != nil {
log.Error("Error artifact not found: %v", err)
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
return
}
@@ -704,7 +726,7 @@ func (r *artifactV4Routes) deleteArtifact(ctx *ArtifactContext) {
}
// get artifact by name
artifact, err := r.getArtifactByName(ctx, runID, ctx.ActionTask.Job.RunAttemptID, req.Name)
artifact, err := r.getOwnAttemptArtifactByName(ctx, runID, ctx.ActionTask.Job.RunAttemptID, req.Name)
if err != nil {
log.Error("Error artifact not found: %v", err)
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
+1 -1
View File
@@ -135,7 +135,7 @@ func CommonRoutes() *web.Router {
r.Group("/{branch}/{repository}", func() {
r.Put("", reqPackageAccess(perm.AccessModeWrite), alpine.UploadPackageFile)
r.Group("/{architecture}", func() {
r.Get("/APKINDEX.tar.gz", alpine.GetRepositoryFile)
r.Methods("HEAD,GET", "/APKINDEX.tar.gz", alpine.GetRepositoryFile)
r.Group("/{filename}", func() {
r.Get("", alpine.DownloadPackageFile)
r.Delete("", reqPackageAccess(perm.AccessModeWrite), alpine.DeletePackageFile)
+17 -1
View File
@@ -9,6 +9,7 @@ import (
"fmt"
"net/url"
"sort"
"time"
packages_model "gitea.dev/models/packages"
npm_module "gitea.dev/modules/packages/npm"
@@ -22,8 +23,14 @@ func createPackageMetadataResponse(registryURL string, pds []*packages_model.Pac
versions := make(map[string]*npm_module.PackageMetadataVersion)
distTags := make(map[string]string)
times := make(map[string]time.Time)
firstPublished, lastPublished := pds[0].Version.CreatedUnix, pds[0].Version.CreatedUnix
for _, pd := range pds {
versions[pd.SemVer.String()] = createPackageMetadataVersion(registryURL, pd)
semVer := pd.SemVer.String()
versions[semVer] = createPackageMetadataVersion(registryURL, pd)
times[semVer] = pd.Version.CreatedUnix.AsTimeInLocation(time.UTC)
firstPublished = min(firstPublished, pd.Version.CreatedUnix)
lastPublished = max(lastPublished, pd.Version.CreatedUnix)
for _, pvp := range pd.VersionProperties {
if pvp.Name == npm_module.TagProperty {
@@ -32,6 +39,10 @@ func createPackageMetadataResponse(registryURL string, pds []*packages_model.Pac
}
}
// npm derives both from the versions currently served, so a deletion moves them
times["created"] = firstPublished.AsTimeInLocation(time.UTC)
times["modified"] = lastPublished.AsTimeInLocation(time.UTC)
latest := pds[len(pds)-1]
metadata := latest.Metadata.(*npm_module.Metadata)
@@ -42,7 +53,10 @@ func createPackageMetadataResponse(registryURL string, pds []*packages_model.Pac
DistTags: distTags,
Description: metadata.Description,
Readme: metadata.Readme,
Maintainers: []npm_module.User{{Name: latest.Owner.Name}},
Time: times,
Homepage: metadata.ProjectURL,
Keywords: metadata.Keywords,
Author: npm_module.User{Name: metadata.Author},
License: metadata.License,
Versions: versions,
@@ -61,8 +75,10 @@ func createPackageMetadataVersion(registryURL string, pd *packages_model.Package
Version: pd.Version.Version,
Description: metadata.Description,
Author: npm_module.User{Name: metadata.Author},
Maintainers: []npm_module.User{{Name: pd.Owner.Name}},
Homepage: metadata.ProjectURL,
License: metadata.License,
Keywords: metadata.Keywords,
Dependencies: metadata.Dependencies,
BundleDependencies: metadata.BundleDependencies,
DevDependencies: metadata.DevelopmentDependencies,
+46
View File
@@ -0,0 +1,46 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package npm
import (
"testing"
"time"
packages_model "gitea.dev/models/packages"
user_model "gitea.dev/models/user"
npm_module "gitea.dev/modules/packages/npm"
"gitea.dev/modules/timeutil"
"github.com/hashicorp/go-version"
"github.com/stretchr/testify/assert"
)
func TestCreatePackageMetadataResponse(t *testing.T) {
descriptor := func(v string, publishedUnix int64) *packages_model.PackageDescriptor {
return &packages_model.PackageDescriptor{
Package: &packages_model.Package{Name: "test"},
Owner: &user_model.User{Name: "alice"},
Version: &packages_model.PackageVersion{Version: v, CreatedUnix: timeutil.TimeStamp(publishedUnix)},
SemVer: version.Must(version.NewVersion(v)),
Metadata: &npm_module.Metadata{Keywords: []string{"gitea"}},
Files: []*packages_model.PackageFileDescriptor{{File: &packages_model.PackageFile{}, Blob: &packages_model.PackageBlob{}}},
}
}
result := createPackageMetadataResponse("https://gitea.dev/api/packages/alice/npm", []*packages_model.PackageDescriptor{
descriptor("1.1.0", 1000),
descriptor("1.0.0", 2000),
})
assert.Equal(t, map[string]time.Time{
"1.0.0": time.Unix(2000, 0).UTC(),
"1.1.0": time.Unix(1000, 0).UTC(),
"created": time.Unix(1000, 0).UTC(),
"modified": time.Unix(2000, 0).UTC(),
}, result.Time)
assert.Equal(t, []npm_module.User{{Name: "alice"}}, result.Maintainers)
assert.Equal(t, []string{"gitea"}, result.Keywords)
assert.Equal(t, []string{"gitea"}, result.Versions["1.0.0"].Keywords)
assert.Equal(t, []npm_module.User{{Name: "alice"}}, result.Versions["1.0.0"].Maintainers)
}
+5 -7
View File
@@ -4,8 +4,9 @@
package misc
import (
"gitea.dev/modules/markup"
"gitea.dev/modules/markup/markdown"
"io"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/util"
"gitea.dev/modules/web"
@@ -84,9 +85,6 @@ func MarkdownRaw(ctx *context.APIContext) {
// "$ref": "#/responses/MarkdownRender"
// "422":
// "$ref": "#/responses/validationError"
defer ctx.Req.Body.Close()
if err := markdown.RenderRaw(markup.NewRenderContext(ctx), ctx.Req.Body, ctx.Resp); err != nil {
ctx.APIErrorInternal(err)
return
}
textBytes, _ := io.ReadAll(io.LimitReader(ctx.Req.Body, setting.UI.MaxDisplayFileSize))
common.RenderMarkup(ctx.Base, ctx.Repo, "markdown", util.UnsafeBytesToString(textBytes), "", "")
}
+27 -56
View File
@@ -7,19 +7,15 @@ import (
go_context "context"
"io"
"net/http"
"os"
"path"
"strings"
"testing"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
"gitea.dev/modules/markup"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/test"
"gitea.dev/modules/web"
context_service "gitea.dev/services/context"
"gitea.dev/services/contexttest"
"github.com/stretchr/testify/assert"
@@ -27,13 +23,6 @@ import (
const AppURL = "http://localhost:3000/"
func TestMain(m *testing.M) {
unittest.MainTest(m, &unittest.TestOptions{
FixtureFiles: []string{"repository.yml", "user.yml"},
})
os.Exit(m.Run())
}
func testRenderMarkup(t *testing.T, mode string, wiki bool, filePath, text, expectedBody string, expectedCode int) {
setting.AppURL = AppURL
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
@@ -49,13 +38,11 @@ func testRenderMarkup(t *testing.T, mode string, wiki bool, filePath, text, expe
FilePath: filePath,
}
ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markup")
ctx.Repo = &context_service.Repository{}
ctx.Repo.Repository = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
web.SetForm(ctx, &options)
Markup(ctx)
assert.Equal(t, expectedBody, resp.Body.String())
assert.Equal(t, expectedCode, resp.Code)
resp.Body.Reset()
assert.Contains(t, resp.Header().Get("Content-Security-Policy"), "script-src * 'nonce-")
}
func testRenderMarkdown(t *testing.T, mode string, wiki bool, text, responseBody string, responseCode int) {
@@ -76,11 +63,10 @@ func testRenderMarkdown(t *testing.T, mode string, wiki bool, text, responseBody
Markdown(ctx)
assert.Equal(t, responseBody, resp.Body.String())
assert.Equal(t, responseCode, resp.Code)
resp.Body.Reset()
assert.Contains(t, resp.Header().Get("Content-Security-Policy"), "script-src * 'nonce-")
}
func TestAPI_RenderGFM(t *testing.T) {
unittest.PrepareTestEnv(t)
markup.Init(&markup.RenderHelperFuncs{
IsUsernameMentionable: func(ctx go_context.Context, username string) bool {
return username == "r-lyeh"
@@ -177,49 +163,34 @@ Here are some links to the most important topics. You can find the full list of
testRenderMarkup(t, "unknown", false, "", "## Test", "unsupported render mode: unknown\n", http.StatusUnprocessableEntity)
}
var simpleCases = []string{
// Guard wiki sidebar: special syntax
`[[Guardfile-DSL / Configuring-Guard|Guardfile-DSL---Configuring-Guard]]`,
// rendered
`<p>[[Guardfile-DSL / Configuring-Guard|Guardfile-DSL---Configuring-Guard]]</p>
`,
// special syntax
`[[Name|Link]]`,
// rendered
`<p>[[Name|Link]]</p>
`,
// empty
``,
// rendered
``,
}
func TestAPI_RenderSimple(t *testing.T) {
setting.AppURL = AppURL
markup.RenderBehaviorForTesting.DisableAdditionalAttributes = true
options := api.MarkdownOption{
Mode: "markdown",
Text: "",
Context: "/user2/repo1",
}
ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markdown")
for i := 0; i < len(simpleCases); i += 2 {
options.Text = simpleCases[i]
web.SetForm(ctx, &options)
Markdown(ctx)
assert.Equal(t, simpleCases[i+1], resp.Body.String())
resp.Body.Reset()
}
}
func TestAPI_RenderRaw(t *testing.T) {
setting.AppURL = AppURL
markup.RenderBehaviorForTesting.DisableAdditionalAttributes = true
ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markdown")
for i := 0; i < len(simpleCases); i += 2 {
ctx.Req.Body = io.NopCloser(strings.NewReader(simpleCases[i]))
MarkdownRaw(ctx)
assert.Equal(t, simpleCases[i+1], resp.Body.String())
resp.Body.Reset()
testCases := []struct {
in, out string
mode string
}{
{in: "", out: ""},
{in: "[[special-syntax]]", out: "<p>[[special-syntax]]</p>\n", mode: "markdown"},
{in: "[[special|syntax]]", out: "<p>[[special|syntax]]</p>\n", mode: "markdown"},
{in: "01234567890123456789", out: "<p>01234567890123456789</p>\n", mode: "gfm"}, // commit-like content should not crash the render
}
t.Run("markdown", func(t *testing.T) {
for _, c := range testCases {
options := api.MarkdownOption{Mode: c.mode, Text: c.in, Context: "/user2/repo1"}
ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markdown")
web.SetForm(ctx, &options)
Markdown(ctx)
assert.Equal(t, c.out, resp.Body.String())
}
})
t.Run("markdown-raw", func(t *testing.T) {
for _, c := range testCases {
ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markdown")
ctx.Req.Body = io.NopCloser(strings.NewReader(c.in))
MarkdownRaw(ctx)
assert.Equal(t, c.out, resp.Body.String())
}
})
}
+1
View File
@@ -40,6 +40,7 @@ func renderServerErrorPage(w http.ResponseWriter, req *http.Request, respCode in
if acceptsHTML {
err := templates.PageRenderer().HTML(outBuf, respCode, tmpl, ctxData, tmplCtx)
if err != nil {
log.Error("Failed to render error page template %s: %v", tmpl, err)
_, _ = w.Write([]byte("Internal server error but failed to render error page template, please collect error logs and report to Gitea issue tracker"))
return
}
+3
View File
@@ -31,6 +31,8 @@ func RenderMarkup(ctx *context.Base, ctxRepo *context.Repository, mode, text, ur
// for example, when previewing file "/gitea/owner/repo/src/branch/features/feat-123/doc/CHANGE.md", then filePath is "doc/CHANGE.md"
// and the urlPathContext is "/gitea/owner/repo/src/branch/features/feat-123/doc"
ctx.SetHeaderContentSecurityPolicyGeneral()
if mode == "" || mode == "markdown" {
// raw Markdown doesn't do any special handling
// TODO: raw markdown doesn't do any link processing, so "urlPathContext" doesn't take effect
@@ -62,6 +64,7 @@ func RenderMarkup(ctx *context.Base, ctxRepo *context.Repository, mode, text, ur
treePath = path.Dir(filePath) // it is "doc" if filePath is "doc/CHANGE.md"
refPath = strings.Join(fields[3:], "/") // it is "branch/features/feat-12/doc"
refPath = strings.TrimSuffix(refPath, "/"+treePath) // now we get the correct branch path: "branch/features/feat-12"
refPath = util.PathEscapeSegments(refPath)
} else if fields = strings.SplitN(repoLinkPath, "/", 3); len(fields) == 2 {
repoOwnerName, repoName = fields[0], fields[1]
}
+8 -6
View File
@@ -73,12 +73,9 @@ func TwoFactorPost(ctx *context.Context) {
return
}
if ctx.Session.Get("linkAccount") != nil {
err = linkAccountFromContext(ctx, u)
if err != nil {
ctx.ServerError("UserSignIn", err)
return
}
if err = completePendingLinks(ctx, u); err != nil {
ctx.ServerError("completePendingLinks", err)
return
}
_ = ctx.Session.Set(session.KeyUserHasTwoFactorAuth, true)
@@ -145,6 +142,11 @@ func TwoFactorScratchPost(ctx *context.Context) {
return
}
if err = completePendingLinks(ctx, u); err != nil {
ctx.ServerError("completePendingLinks", err)
return
}
handleSignInFull(ctx, u, remember)
if ctx.Written() {
return
+18 -27
View File
@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"html/template"
"maps"
"net/http"
"net/url"
"strings"
@@ -329,46 +330,35 @@ func SignInPost(ctx *context.Context) {
// If this user is enrolled in 2FA TOTP, we can't sign the user in just yet.
// Instead, redirect them to the 2FA authentication page.
hasTOTPtwofa, err := auth.HasTwoFactorByUID(ctx, u.ID)
hasTwoFactor, err := auth.HasTwoFactorOrWebAuthn(ctx, u.ID)
if err != nil {
ctx.ServerError("UserSignIn", err)
ctx.ServerError("HasTwoFactorOrWebAuthn", err)
return
}
// Check if the user has webauthn registration
hasWebAuthnTwofa, err := auth.HasWebAuthnRegistrationsByUID(ctx, u.ID)
if err != nil {
ctx.ServerError("UserSignIn", err)
return
}
if !hasTOTPtwofa && !hasWebAuthnTwofa {
// No two-factor auth configured we can sign in the user
if !hasTwoFactor {
handleSignIn(ctx, u, form.Remember)
return
}
updates := map[string]any{
// User will need to use 2FA TOTP or WebAuthn, save data
"twofaUid": u.ID,
"twofaRemember": form.Remember,
}
if hasTOTPtwofa {
// User will need to use WebAuthn, save data
updates["totpEnrolled"] = u.ID
}
handleTwoFactorRequired(ctx, u, form.Remember, nil)
}
func handleTwoFactorRequired(ctx *context.Context, u *user_model.User, remember bool, extra map[string]any) {
updates := map[string]any{"twofaUid": u.ID, "twofaRemember": remember}
maps.Copy(updates, extra)
if err := regenerateSession(ctx, nil, updates); err != nil {
ctx.ServerError("UserSignIn: Unable to update session", err)
ctx.ServerError("RegenerateSession", err)
return
}
// If we have WebAuthn redirect there first
if hasWebAuthnTwofa {
hasWebAuthn, err := auth.HasWebAuthnRegistrationsByUID(ctx, u.ID)
if err != nil {
ctx.ServerError("HasWebAuthnRegistrationsByUID", err)
return
}
if hasWebAuthn {
ctx.Redirect(setting.AppSubURL + "/user/webauthn")
return
}
// Fallback to 2FA
ctx.Redirect(setting.AppSubURL + "/user/two_factor")
}
@@ -408,6 +398,7 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember bool) {
"twofaRemember",
"linkAccount",
"linkAccountData",
"openidPendingURI",
}, map[string]any{
session.KeyUID: u.ID,
session.KeyUname: u.Name,
+17
View File
@@ -11,6 +11,7 @@ import (
"testing"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/session"
"gitea.dev/modules/setting"
@@ -182,3 +183,19 @@ func TestWebAuthOAuth2(t *testing.T) {
})
})
}
func TestOpenIDRequireTwoFactor(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
mockOpt := contexttest.MockContextOption{SessionStore: session.NewMockMemStore("dummy-sid-openid")}
user32 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 32}) // has a webauthn credential
ctx, resp := contexttest.MockContext(t, "/user/openid/connect", mockOpt)
openIDRequireTwoFactor(ctx, user32, false, "https://example.com/id")
assert.Equal(t, "/user/webauthn", test.RedirectURL(resp))
unittest.AssertNotExistsBean(t, &user_model.UserOpenID{UID: user32.ID}) // not attached before the key answered
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
ctx, _ = contexttest.MockContext(t, "/user/openid/connect", mockOpt)
openIDRequireTwoFactor(ctx, user2, false, "https://example.com/id")
assert.False(t, ctx.Written())
}
+17 -25
View File
@@ -148,15 +148,13 @@ func oauth2LinkAccount(ctx *context.Context, u *user_model.User, linkAccountData
// If this user is enrolled in 2FA, we can't sign the user in just yet.
// Instead, redirect them to the 2FA authentication page.
// We deliberately ignore the skip local 2fa setting here because we are linking to a previous user here
_, err := auth.GetTwoFactorByUID(ctx, u.ID)
hasTwoFactor, err := auth.HasTwoFactorOrWebAuthn(ctx, u.ID)
if err != nil {
if !auth.IsErrTwoFactorNotEnrolled(err) {
ctx.ServerError("UserLinkAccount", err)
return
}
err = externalaccount.LinkAccountToUser(ctx, linkAccountData.AuthSourceID, u, linkAccountData.GothUser)
if err != nil {
ctx.ServerError("UserLinkAccount", err)
return
}
if !hasTwoFactor {
if err := externalaccount.LinkAccountToUser(ctx, linkAccountData.AuthSourceID, u, linkAccountData.GothUser); err != nil {
ctx.ServerError("UserLinkAccount", err)
return
}
@@ -170,25 +168,10 @@ func oauth2LinkAccount(ctx *context.Context, u *user_model.User, linkAccountData
return
}
if err := regenerateSession(ctx, nil, map[string]any{
// User needs to use 2FA, save data and redirect to 2FA page.
"twofaUid": u.ID,
"twofaRemember": remember,
handleTwoFactorRequired(ctx, u, remember, map[string]any{
"linkAccount": true,
session.KeySignInMethod: session.SignInMethodOAuth2,
}); err != nil {
ctx.ServerError("RegenerateSession", err)
return
}
// If WebAuthn is enrolled -> Redirect to WebAuthn instead
regs, err := auth.GetWebAuthnCredentialsByUID(ctx, u.ID)
if err == nil && len(regs) > 0 {
ctx.Redirect(setting.AppSubURL + "/user/webauthn")
return
}
ctx.Redirect(setting.AppSubURL + "/user/two_factor")
})
}
// LinkAccountPostRegister handle the creation of a new account for an external account using signUp
@@ -279,6 +262,15 @@ func LinkAccountPostRegister(ctx *context.Context) {
handleSignIn(ctx, u, false)
}
func completePendingLinks(ctx *context.Context, user *user_model.User) error {
if ctx.Session.Get("linkAccount") != nil {
if err := linkAccountFromContext(ctx, user); err != nil {
return err
}
}
return openIDConnectFromContext(ctx, user)
}
func linkAccountFromContext(ctx *context.Context, user *user_model.User) error {
linkAccountData := oauth2GetLinkAccountData(ctx)
if linkAccountData == nil {
+3 -21
View File
@@ -361,12 +361,11 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
needs2FA := false
if !authSource.TwoFactorShouldSkip() {
_, err := auth.GetTwoFactorByUID(ctx, u.ID)
if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) {
var err error
if needs2FA, err = auth.HasTwoFactorOrWebAuthn(ctx, u.ID); err != nil {
ctx.ServerError("UserSignIn", err)
return
}
needs2FA = err == nil
}
oauth2Source := authSource.Cfg.(*oauth2.Source)
@@ -454,24 +453,7 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
}
}
if err := regenerateSession(ctx, nil, map[string]any{
// User needs to use 2FA, save data and redirect to 2FA page.
"twofaUid": u.ID,
"twofaRemember": false,
session.KeySignInMethod: session.SignInMethodOAuth2,
}); err != nil {
ctx.ServerError("updateSession", err)
return
}
// If WebAuthn is enrolled -> Redirect to WebAuthn instead
regs, err := auth.GetWebAuthnCredentialsByUID(ctx, u.ID)
if err == nil && len(regs) > 0 {
ctx.Redirect(setting.AppSubURL + "/user/webauthn")
return
}
ctx.Redirect(setting.AppSubURL + "/user/two_factor")
handleTwoFactorRequired(ctx, u, false, map[string]any{session.KeySignInMethod: session.SignInMethodOAuth2})
}
// OAuth2UserLoginCallback attempts to handle the callback from the OAuth2 provider and if successful
+41 -4
View File
@@ -8,6 +8,7 @@ import (
"net/http"
"net/url"
auth_model "gitea.dev/models/auth"
user_model "gitea.dev/models/user"
"gitea.dev/modules/auth/openid"
"gitea.dev/modules/log"
@@ -26,6 +27,36 @@ const (
tplSignUpOID templates.TplName = "user/auth/signup_openid_register"
)
// the OpenID is attached only after the second factor passed, so a stolen password cannot leave one behind
func openIDRequireTwoFactor(ctx *context.Context, u *user_model.User, remember bool, pendingURI string) {
hasTwoFactor, err := auth_model.HasTwoFactorOrWebAuthn(ctx, u.ID)
if err != nil {
ctx.ServerError("HasTwoFactorOrWebAuthn", err)
return
}
if !hasTwoFactor {
return
}
handleTwoFactorRequired(ctx, u, remember, map[string]any{"openidPendingURI": pendingURI})
}
func openIDConnectFromContext(ctx *context.Context, u *user_model.User) error {
uri, _ := ctx.Session.Get("openidPendingURI").(string)
if uri == "" {
return nil
}
if err := ctx.Session.Delete("openidPendingURI"); err != nil {
return err
}
if err := user_model.AddUserOpenID(ctx, &user_model.UserOpenID{UID: u.ID, URI: uri}); err != nil {
if !user_model.IsErrOpenIDAlreadyUsed(err) {
return err
}
ctx.Flash.Error(ctx.Tr("form.openid_been_used", uri))
}
return nil
}
// SignInOpenID render sign in page
func SignInOpenID(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("sign_in")
@@ -154,6 +185,10 @@ func signInOpenIDVerify(ctx *context.Context) {
log.Trace("User exists, logging in")
remember, _ := ctx.Session.Get("openid_signin_remember").(bool)
log.Trace("Session stored openid-remember: %t", remember)
openIDRequireTwoFactor(ctx, u, remember, "")
if ctx.Written() {
return
}
handleSignIn(ctx, u, remember)
return
}
@@ -270,7 +305,12 @@ func ConnectOpenIDPost(ctx *context.Context) {
return
}
// add OpenID for the user
remember, _ := ctx.Session.Get("openid_signin_remember").(bool)
openIDRequireTwoFactor(ctx, u, remember, oid)
if ctx.Written() {
return
}
userOID := &user_model.UserOpenID{UID: u.ID, URI: oid}
if err := user_model.AddUserOpenID(ctx, userOID); err != nil {
if user_model.IsErrOpenIDAlreadyUsed(err) {
@@ -282,9 +322,6 @@ func ConnectOpenIDPost(ctx *context.Context) {
}
ctx.Flash.Success(ctx.Tr("settings.add_openid_success"))
remember, _ := ctx.Session.Get("openid_signin_remember").(bool)
log.Trace("Session stored openid-remember: %t", remember)
handleSignIn(ctx, u, remember)
}
+13
View File
@@ -238,6 +238,19 @@ func ResetPasswdPost(ctx *context.Context) {
return
}
// the reset form only carries a TOTP field, so a WebAuthn-only user finishes on its own page
if twofa == nil {
hasWebAuthn, err := auth.HasWebAuthnRegistrationsByUID(ctx, u.ID)
if err != nil {
ctx.ServerError("HasWebAuthnRegistrationsByUID", err)
return
}
if hasWebAuthn {
handleTwoFactorRequired(ctx, u, remember, nil)
return
}
}
handleSignIn(ctx, u, remember)
}
+11 -15
View File
@@ -54,7 +54,8 @@ func WebAuthnPasskeyAssertion(ctx *context.Context) {
return
}
assertion, sessionData, err := wa.WebAuthn.BeginDiscoverableLogin()
// a passkey is the only factor here
assertion, sessionData, err := wa.WebAuthn.BeginDiscoverableLogin(webauthn.WithUserVerification(protocol.VerificationRequired))
if err != nil {
ctx.ServerError("webauthn.BeginDiscoverableLogin", err)
return
@@ -91,7 +92,7 @@ func WebAuthnPasskeyLogin(ctx *context.Context) {
parsedResponse, err := protocol.ParseCredentialRequestResponse(ctx.Req)
if err != nil {
// Failed authentication attempt.
log.Info("Failed authentication attempt for %s from %s: %v", user.Name, ctx.RemoteAddr(), err)
log.Info("Failed authentication attempt from %s: %v", ctx.RemoteAddr(), err)
ctx.Status(http.StatusForbidden)
return
}
@@ -147,12 +148,9 @@ func WebAuthnPasskeyLogin(ctx *context.Context) {
return
}
// Now handle account linking if that's requested
if ctx.Session.Get("linkAccount") != nil {
if err := linkAccountFromContext(ctx, user); err != nil {
ctx.ServerError("LinkAccountFromStore", err)
return
}
if err := completePendingLinks(ctx, user); err != nil {
ctx.ServerError("completePendingLinks", err)
return
}
remember := false // TODO: implement remember me
@@ -186,7 +184,8 @@ func WebAuthnLoginAssertion(ctx *context.Context) {
}
webAuthnUser := wa.NewWebAuthnUser(ctx, user)
assertion, sessionData, err := wa.WebAuthn.BeginLogin(webAuthnUser)
// "discouraged" would hide credProtect protected credentials
assertion, sessionData, err := wa.WebAuthn.BeginLogin(webAuthnUser, webauthn.WithUserVerification(protocol.VerificationPreferred))
if err != nil {
ctx.ServerError("webauthn.BeginLogin", err)
return
@@ -261,12 +260,9 @@ func WebAuthnLoginAssertionPost(ctx *context.Context) {
return
}
// Now handle account linking if that's requested
if ctx.Session.Get("linkAccount") != nil {
if err := linkAccountFromContext(ctx, user); err != nil {
ctx.ServerError("LinkAccountFromStore", err)
return
}
if err := completePendingLinks(ctx, user); err != nil {
ctx.ServerError("completePendingLinks", err)
return
}
remember := ctx.Session.Get("twofaRemember").(bool)
+1 -2
View File
@@ -5,7 +5,6 @@ package org
import (
"net/http"
"path"
"strings"
"gitea.dev/models/db"
@@ -201,7 +200,7 @@ func prepareOrgProfileReadme(ctx *context.Context, prepareResult *shared_user.Pr
}
rctx := renderhelper.NewRenderContextRepoFile(ctx, profileRepo, renderhelper.RepoFileOptions{
CurrentRefSubURL: path.Join("branch", util.PathEscapeSegments(profileRepo.DefaultBranch)),
CurrentRefSubURL: git.RefNameFromBranch(profileRepo.DefaultBranch).RefWebLinkPath(),
})
ctx.Data["ProfileReadmeContent"], err = markdown.RenderString(rctx, readmeBytes)
if err != nil {
+9 -3
View File
@@ -1063,7 +1063,10 @@ func Cancel(ctx *context_module.Context) {
return fmt.Errorf("cancel jobs: %w", err)
}
updatedJobs = append(updatedJobs, cancelledJobs...)
return nil
if len(updatedJobs) > 0 {
return nil // a job update already refreshed the run
}
return actions_model.SettleRunAfterCancel(ctx, run)
}); err != nil {
ctx.ServerError("StopTask", err)
return
@@ -1073,8 +1076,11 @@ func Cancel(ctx *context_module.Context) {
actions_service.EmitJobsIfReadyByJobs(updatedJobs)
actions_service.NotifyWorkflowJobsStatusUpdate(ctx, updatedJobs...)
if len(updatedJobs) > 0 {
actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, run.RepoID, run.ID)
// SettleRunAfterCancel finishes a run without updating any job, so compare the run itself.
if reloaded, err := actions_model.GetRunByRepoAndID(ctx, run.RepoID, run.ID); err != nil {
log.Error("GetRunByRepoAndID: %v", err)
} else if len(updatedJobs) > 0 || reloaded.Status != run.Status {
actions_service.NotifyWorkflowRunStatusUpdate(ctx, reloaded)
}
ctx.JSONOK()
}
+1 -1
View File
@@ -223,7 +223,7 @@ func processBlameParts(ctx *context.Context, blameParts []*gitrepo.BlamePart) ma
}
func renderBlameFillFirstBlameRow(ctx *context.Context, repoLink string, part *gitrepo.BlamePart, commit *gituser.UserCommit, br *blameRow) {
br.AvatarStackData = gituser.BuildAvatarStackData(ctx, commit.GitCommit.AllParticipantIdentities(), nil)
br.AvatarStackData = gituser.BuildAvatarStackData(ctx, commit.GitCommit.AllAuthorIdentities(), nil)
br.PreviousSha = part.PreviousSha
br.PreviousShaURL = fmt.Sprintf("%s/blame/commit/%s/%s", repoLink, url.PathEscape(part.PreviousSha), util.PathEscapeSegments(part.PreviousPath))
br.CommitURL = fmt.Sprintf("%s/commit/%s", repoLink, url.PathEscape(part.Sha))
+3
View File
@@ -64,6 +64,9 @@ func RenderFile(ctx *context.Context) {
extRendererOpts := extRenderer.GetExternalRendererOptions()
if extRendererOpts.ContentSandbox != "" {
ctx.Resp.Header().Add("Content-Security-Policy", "sandbox "+extRendererOpts.ContentSandbox)
} else {
// if no sandbox, just apply the same CSP as a general Gitea web page
ctx.SetHeaderContentSecurityPolicyGeneral()
}
err = markup.RenderWithRenderer(rctx, renderer, rendererInput, ctx.Resp)
+12 -6
View File
@@ -118,13 +118,19 @@ func CollaborationPost(ctx *context.Context) {
// ChangeCollaborationAccessMode response for changing access of a collaboration
func ChangeCollaborationAccessMode(ctx *context.Context) {
if err := repo_model.ChangeCollaborationAccessMode(
ctx,
ctx.Repo.Repository,
ctx.FormInt64("uid"),
perm.AccessMode(ctx.FormInt("mode"))); err != nil {
log.Error("ChangeCollaborationAccessMode: %v", err)
// the frontend initRepoSettingsCollaboration logic: it only checks "resp.ok"
u, err := user_model.GetUserByID(ctx, ctx.FormInt64("uid"))
if err != nil {
ctx.Status(http.StatusBadRequest)
return
}
mode := perm.AccessMode(ctx.FormInt("mode"))
if err := repo_service.AddOrUpdateCollaborator(ctx, ctx.Repo.Repository, u, mode); err != nil {
ctx.Status(http.StatusBadRequest)
log.Error("AddOrUpdateCollaborator: %v", err)
return
}
ctx.JSONOK()
}
// DeleteCollaboration delete a collaboration for a repository
+1 -1
View File
@@ -134,7 +134,7 @@ func loadLatestCommitData(ctx *context.Context, latestCommit *git.Commit) bool {
return false
}
avatarStackData := gituser.BuildAvatarStackData(ctx, latestCommit.AllParticipantIdentities(), nil)
avatarStackData := gituser.BuildAvatarStackData(ctx, latestCommit.AllAuthorIdentities(), nil)
avatarStackData.SearchByEmailLink = gituser.RepoCommitSearchByEmailLink(ctx.Repo.RepoLink, ctx.Repo.RefFullName)
ctx.Data["LatestCommitAvatarStackData"] = avatarStackData
ctx.Data["LatestCommitVerification"] = verification
+1 -3
View File
@@ -7,7 +7,6 @@ package user
import (
"fmt"
"net/http"
"path"
"strings"
activities_model "gitea.dev/models/activities"
@@ -22,7 +21,6 @@ import (
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/util"
"gitea.dev/routers/web/feed"
"gitea.dev/routers/web/org"
shared_user "gitea.dev/routers/web/shared/user"
@@ -255,7 +253,7 @@ func prepareUserProfileTabData(ctx *context.Context, profileDbRepo *repo_model.R
log.Error("failed to GetBlobContent: %v", err)
} else {
rctx := renderhelper.NewRenderContextRepoFile(ctx, profileDbRepo, renderhelper.RepoFileOptions{
CurrentRefSubURL: path.Join("branch", util.PathEscapeSegments(profileDbRepo.DefaultBranch)),
CurrentRefSubURL: git.RefNameFromBranch(profileDbRepo.DefaultBranch).RefWebLinkPath(),
})
if profileContent, err := markdown.RenderString(rctx, bytes); err != nil {
log.Error("failed to RenderString: %v", err)
+10 -1
View File
@@ -53,8 +53,17 @@ func WebAuthnRegister(ctx *context.Context) {
}
webAuthnUser := wa.NewWebAuthnUser(ctx, ctx.Doer)
credentialOptions, sessionData, err := wa.WebAuthn.BeginRegistration(webAuthnUser, webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{
// the exclusions stop enrolling the same authenticator twice
credentials, err := auth.GetWebAuthnCredentialsByUID(ctx, ctx.Doer.ID)
if err != nil {
ctx.ServerError("GetWebAuthnCredentialsByUID", err)
return
}
exclusions := webauthn.Credentials(credentials.ToCredentials()).CredentialDescriptors()
credentialOptions, sessionData, err := wa.WebAuthn.BeginRegistration(webAuthnUser, webauthn.WithExclusions(exclusions), webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{
ResidentKey: protocol.ResidentKeyRequirementRequired,
// anything else makes Chromium raise it to credProtect level 3, hiding it from the second factor
UserVerification: protocol.VerificationRequired,
}))
if err != nil {
ctx.ServerError("Unable to BeginRegistration", err)
+18
View File
@@ -8,8 +8,10 @@ import (
"fmt"
actions_model "gitea.dev/models/actions"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/json"
"gitea.dev/modules/log"
api "gitea.dev/modules/structs"
)
@@ -50,6 +52,22 @@ func getInputsForJob(ctx context.Context, run *actions_model.ActionRun, job *act
return p.Inputs, nil
}
// pullRequestTargetBaseSHA returns the base branch commit of a pull_request_target run, and whether the run is one.
func pullRequestTargetBaseSHA(run *actions_model.ActionRun) (string, bool) {
if run.TriggerEvent != actions_module.GithubEventPullRequestTarget {
return "", false
}
payload, err := run.GetPullRequestEventPayload()
if err != nil {
log.Error("run %d: get pull request event payload: %v", run.ID, err)
return "", false
}
if payload.PullRequest == nil || payload.PullRequest.Base == nil || payload.PullRequest.Base.Sha == "" {
return "", false
}
return payload.PullRequest.Base.Sha, true
}
// evaluateJobIf evaluates a job's `if:`
func evaluateJobIf(ctx context.Context, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt, job *actions_model.ActionRunJob, vars map[string]string, allNeedsSucceed bool) (bool, error) {
parsedJob, err := job.ParseJob()
+73
View File
@@ -0,0 +1,73 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"testing"
actions_model "gitea.dev/models/actions"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/json"
api "gitea.dev/modules/structs"
webhook_module "gitea.dev/modules/webhook"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPullRequestTargetBaseSHA(t *testing.T) {
prPayload := func(baseSHA string) string {
payload, err := json.Marshal(api.PullRequestPayload{
PullRequest: &api.PullRequest{
Base: &api.PRBranchInfo{Sha: baseSHA},
},
})
require.NoError(t, err)
return string(payload)
}
t.Run("pull_request_target with base SHA", func(t *testing.T) {
run := &actions_model.ActionRun{
Event: webhook_module.HookEventPullRequest,
TriggerEvent: actions_module.GithubEventPullRequestTarget,
EventPayload: prPayload("base-sha"),
}
got, ok := pullRequestTargetBaseSHA(run)
assert.True(t, ok)
assert.Equal(t, "base-sha", got)
})
t.Run("non pull_request_target trigger", func(t *testing.T) {
run := &actions_model.ActionRun{
Event: webhook_module.HookEventPullRequest,
TriggerEvent: actions_module.GithubEventPullRequest,
EventPayload: prPayload("base-sha"),
}
got, ok := pullRequestTargetBaseSHA(run)
assert.False(t, ok)
assert.Empty(t, got)
})
t.Run("missing base SHA", func(t *testing.T) {
run := &actions_model.ActionRun{
Event: webhook_module.HookEventPullRequest,
TriggerEvent: actions_module.GithubEventPullRequestTarget,
EventPayload: prPayload(""),
}
got, ok := pullRequestTargetBaseSHA(run)
assert.False(t, ok)
assert.Empty(t, got)
})
t.Run("invalid payload", func(t *testing.T) {
run := &actions_model.ActionRun{
Event: webhook_module.HookEventPullRequest,
TriggerEvent: actions_module.GithubEventPullRequestTarget,
EventPayload: "{",
}
got, ok := pullRequestTargetBaseSHA(run)
assert.False(t, ok)
assert.Empty(t, got)
})
}
+14 -11
View File
@@ -338,8 +338,8 @@ func handleWorkflows(
isForkPullRequest := isForkPullRequestInput(input)
for _, dwf := range detectedWorkflows {
// repo-level run: the workflow content is this repo at this commit
if err := buildApproveAndInsertRun(ctx, input, ref, commit, string(p), isForkPullRequest, dwf, input.Repo.ID, commit.ID.String(), false); err != nil {
// repo-level run: the workflow content is this repo at dwf.SourceCommitSHA
if err := buildApproveAndInsertRun(ctx, input, ref, commit, string(p), isForkPullRequest, dwf, input.Repo.ID, false); err != nil {
log.Error("repo %s: %v", input.Repo.RelativePath(), err)
continue
}
@@ -350,7 +350,7 @@ func handleWorkflows(
// buildApproveAndInsertRun assembles an ActionRun for a detected workflow, runs the
// fork-PR approval gate, and inserts it. Repo-level and scoped runs share this path so
// run construction and the approval flow have a single implementation that can't drift.
// workflowRepoID/workflowCommitSHA point at the repo+commit the workflow content comes
// workflowRepoID and dwf.SourceCommitSHA point at the repo+commit the workflow content comes
// from (the repo itself for repo-level runs, the source repo for scoped runs).
func buildApproveAndInsertRun(
ctx context.Context,
@@ -361,9 +361,12 @@ func buildApproveAndInsertRun(
isForkPullRequest bool,
dwf *actions_module.DetectedWorkflow,
workflowRepoID int64,
workflowCommitSHA string,
isScopedRun bool,
) error {
if dwf.SourceCommitSHA == "" {
// unreachable in the normal flow; catches a test case that builds a DetectedWorkflow without it
setting.PanicInDevOrTesting("workflow %q has no source commit", dwf.EntryName)
}
run := &actions_model.ActionRun{
Title: commit.MessageTitle(),
RepoID: input.Repo.ID,
@@ -380,7 +383,7 @@ func buildApproveAndInsertRun(
TriggerEvent: dwf.TriggerEvent.Name,
Status: actions_model.StatusWaiting,
WorkflowRepoID: workflowRepoID,
WorkflowCommitSHA: workflowCommitSHA,
WorkflowCommitSHA: dwf.SourceCommitSHA,
IsScopedRun: isScopedRun,
}
@@ -695,7 +698,7 @@ func detectAndHandleScopedWorkflows(
continue
}
sourceCommitSHA, detected, filtered, err := detectScopedWorkflowsForSource(ctx, input, consumerGitRepo, consumerCommit, sourceRepo)
detected, filtered, err := detectScopedWorkflowsForSource(ctx, input, consumerGitRepo, consumerCommit, sourceRepo)
if err != nil {
log.Error("scoped workflows: source %d for consumer %s: %v", sourceRepoID, input.Repo.RelativePath(), err)
continue
@@ -708,7 +711,7 @@ func detectAndHandleScopedWorkflows(
continue
}
if err := buildApproveAndInsertRun(ctx, input, ref, consumerCommit, string(p), isForkPullRequest, dwf, sourceRepo.ID, sourceCommitSHA, true); err != nil {
if err := buildApproveAndInsertRun(ctx, input, ref, consumerCommit, string(p), isForkPullRequest, dwf, sourceRepo.ID, true); err != nil {
log.Error("scoped workflows: source %s workflow %s: %v", sourceRepo.RelativePath(), dwf.EntryName, err)
continue
}
@@ -741,12 +744,12 @@ func detectScopedWorkflowsForSource(
consumerGitRepo *git.Repository,
consumerCommit *git.Commit,
sourceRepo *repo_model.Repository,
) (sourceCommitSHA string, detected, filtered []*actions_module.DetectedWorkflow, err error) {
) (detected, filtered []*actions_module.DetectedWorkflow, err error) {
// scoped workflow content is always taken from the source repo's default branch; the parse is cached per (source, default-branch SHA) and reused across consuming repos/events
sourceCommitSHA, parsed, err := LoadParsedScopedWorkflows(ctx, sourceRepo)
if err != nil {
return "", nil, nil, err
return nil, nil, err
}
detected, filtered = actions_module.MatchScopedWorkflows(parsed, consumerGitRepo, consumerCommit, input.Event, input.Payload)
return sourceCommitSHA, detected, filtered, nil
detected, filtered = actions_module.MatchScopedWorkflows(parsed, sourceCommitSHA, consumerGitRepo, consumerCommit, input.Event, input.Payload)
return detected, filtered, nil
}
+19 -1
View File
@@ -20,6 +20,7 @@ import (
"gitea.dev/modules/gitrepo"
"gitea.dev/modules/httplib"
"gitea.dev/modules/json"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/util"
@@ -60,7 +61,11 @@ func loadReusableWorkflowSource(ctx context.Context, run *actions_model.ActionRu
if err != nil {
return nil, 0, "", fmt.Errorf("look up caller source repo %d: %w", caller.WorkflowSourceRepoID, err)
}
bytes, resolvedSHA, err := readWorkflowFromRepo(ctx, callerRepo, caller.WorkflowSourceCommitSHA, ref.Path)
sourceCommitSHA := resolveSameRepoWorkflowSourceCommit(run, caller)
if sourceCommitSHA != caller.WorkflowSourceCommitSHA {
log.Warn("run %d (pull_request_target) records workflow source commit %s, resolving %q at base commit %s instead", run.ID, caller.WorkflowSourceCommitSHA, ref.Path, sourceCommitSHA)
}
bytes, resolvedSHA, err := readWorkflowFromRepo(ctx, callerRepo, sourceCommitSHA, ref.Path)
if err != nil {
return nil, 0, "", err
}
@@ -92,6 +97,19 @@ func loadReusableWorkflowSource(ctx context.Context, run *actions_model.ActionRu
return nil, 0, "", fmt.Errorf("unsupported uses kind %d", ref.Kind)
}
// resolveSameRepoWorkflowSourceCommit returns the commit to read a same-repo reusable workflow from.
// pull_request_target runs must resolve local `uses:` at the PR base commit, not a stored head SHA.
func resolveSameRepoWorkflowSourceCommit(run *actions_model.ActionRun, caller *actions_model.ActionRunJob) string {
// only a SHA copied from the run row can be the polluted head one; a SHA resolved from a `uses:` ref is right by construction
if run.IsScopedRun || caller.WorkflowSourceRepoID != run.RepoID || caller.WorkflowSourceCommitSHA != run.WorkflowCommitSHA {
return caller.WorkflowSourceCommitSHA
}
if baseSHA, ok := pullRequestTargetBaseSHA(run); ok && baseSHA != caller.WorkflowSourceCommitSHA {
return baseSHA
}
return caller.WorkflowSourceCommitSHA
}
// readWorkflowFromRepo loads a workflow file from `repo` at `refOrSHA` and returns its content plus the resolved commit SHA.
func readWorkflowFromRepo(ctx context.Context, repo *repo_model.Repository, refOrSHA, path string) ([]byte, string, error) {
gitRepo, err := gitrepo.OpenRepository(ctx, repo)
@@ -10,9 +10,13 @@ import (
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
"gitea.dev/models/unittest"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/json"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/test"
webhook_module "gitea.dev/modules/webhook"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -278,3 +282,74 @@ func TestUndoExpansion(t *testing.T) {
assert.False(t, refreshed.IsExpanded)
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: sibling.ID})
}
func TestResolveSameRepoWorkflowSourceCommit(t *testing.T) {
prtRun := func(baseSHA string) *actions_model.ActionRun {
payload, err := json.Marshal(api.PullRequestPayload{
PullRequest: &api.PullRequest{
Base: &api.PRBranchInfo{Sha: baseSHA},
},
})
require.NoError(t, err)
// a run recorded before the fix points at the PR head commit
return &actions_model.ActionRun{
ID: 42,
RepoID: 1,
Event: webhook_module.HookEventPullRequest,
TriggerEvent: actions_module.GithubEventPullRequestTarget,
EventPayload: string(payload),
WorkflowCommitSHA: "head-sha",
}
}
pushRun := &actions_model.ActionRun{
RepoID: 1,
TriggerEvent: "push",
WorkflowCommitSHA: "head-sha",
}
caller := func(sourceRepoID int64, sourceCommitSHA string) *actions_model.ActionRunJob {
return &actions_model.ActionRunJob{WorkflowSourceRepoID: sourceRepoID, WorkflowSourceCommitSHA: sourceCommitSHA}
}
t.Run("pull_request_target pins to base commit", func(t *testing.T) {
got := resolveSameRepoWorkflowSourceCommit(prtRun("base-sha"), caller(1, "head-sha"))
assert.Equal(t, "base-sha", got)
})
t.Run("legacy nested caller (with head-sha) pins to base commit", func(t *testing.T) {
nested := caller(1, "head-sha")
nested.ParentJobID = 99
got := resolveSameRepoWorkflowSourceCommit(prtRun("base-sha"), nested)
assert.Equal(t, "base-sha", got)
})
t.Run("pull_request_target keeps stored SHA when already base", func(t *testing.T) {
run := prtRun("base-sha")
run.WorkflowCommitSHA = "base-sha"
got := resolveSameRepoWorkflowSourceCommit(run, caller(1, "base-sha"))
assert.Equal(t, "base-sha", got)
})
t.Run("non pull_request_target keeps stored SHA", func(t *testing.T) {
got := resolveSameRepoWorkflowSourceCommit(pushRun, caller(1, "head-sha"))
assert.Equal(t, "head-sha", got)
})
t.Run("scoped run keeps stored SHA", func(t *testing.T) {
run := prtRun("base-sha")
run.IsScopedRun = true
got := resolveSameRepoWorkflowSourceCommit(run, caller(1, "head-sha"))
assert.Equal(t, "head-sha", got)
})
t.Run("cross-repo caller keeps stored SHA", func(t *testing.T) {
got := resolveSameRepoWorkflowSourceCommit(prtRun("base-sha"), caller(2, "head-sha"))
assert.Equal(t, "head-sha", got)
})
t.Run("caller resolved from a uses: ref keeps its own SHA", func(t *testing.T) {
nested := caller(1, "tag-v1-sha")
nested.ParentJobID = 99
got := resolveSameRepoWorkflowSourceCommit(prtRun("base-sha"), nested)
assert.Equal(t, "tag-v1-sha", got)
})
}
-2
View File
@@ -78,8 +78,6 @@ func (h *HTTPSign) Verify(req *http.Request, w http.ResponseWriter, store DataSt
return nil, err
}
store.GetData()["IsApiToken"] = true
log.Trace("HTTP Sign: Logged in user %-v", u)
return u, nil
+20
View File
@@ -188,6 +188,26 @@ func (b *Base) TrN(cnt any, key1, keyN string, args ...any) template.HTML {
return b.Locale.TrN(cnt, key1, keyN, args...)
}
func CspScriptNonce(ctx reqctx.RequestContext) (ret string) {
// Generate a random nonce for each request and cache it in the context to make it usable during the whole rendering process.
//
// Some "<script>" tags are not in the CSP context, so they don't need nonce,
// these tags are written as "<script nonce>" to help developers to know that "no script nonce attribute is missing"
// (e.g.: when they grep the codebase for "script" tags)
ret, _ = ctx.Value("_cspScriptNonce").(string)
if ret == "" {
ret = util.FastCryptoRandomHex(32) // 16 bytes / 128 bits entropy
ctx.SetContextValue("_cspScriptNonce", ret)
}
return ret
}
func (b *Base) SetHeaderContentSecurityPolicyGeneral() {
if csp := WebContentSecurityPolicy(CspScriptNonce(b)); csp != "" {
b.Resp.Header().Set("Content-Security-Policy", csp)
}
}
func NewBaseContext(resp http.ResponseWriter, req *http.Request) *Base {
reqCtx := reqctx.FromContext(req.Context())
b := &Base{
+25 -27
View File
@@ -12,10 +12,11 @@ import (
"strings"
"time"
"gitea.dev/modules/htmlutil"
"gitea.dev/modules/httplib"
"gitea.dev/modules/public"
"gitea.dev/modules/reqctx"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
"gitea.dev/modules/web/middleware"
"gitea.dev/services/webtheme"
)
@@ -24,16 +25,16 @@ type TemplateContext map[string]any
var _ context.Context = TemplateContext(nil)
func NewTemplateContext(ctx context.Context, req *http.Request) TemplateContext {
func NewTemplateContext(ctx reqctx.RequestContext, req *http.Request) TemplateContext {
return TemplateContext{"_ctx": ctx, "_req": req}
}
func (c TemplateContext) req() *http.Request {
return c["_req"].(*http.Request)
return c["_req"].(*http.Request) //nolint:forcetypeassert // must exist
}
func (c TemplateContext) parentContext() context.Context {
return c["_ctx"].(context.Context)
func (c TemplateContext) parentContext() reqctx.RequestContext {
return c["_ctx"].(reqctx.RequestContext) //nolint:forcetypeassert // must exist
}
func (c TemplateContext) Deadline() (deadline time.Time, ok bool) {
@@ -78,7 +79,7 @@ func (c TemplateContext) CurrentWebBanner() *setting.WebBannerType {
return nil
}
// AppFullLink returns a full URL link with AppSubURL for the given app link (no AppSubURL)
// AppFullLink returns a full URL link with AppSubURL for the given app link
// If no link is given, it returns the current app full URL with sub-path but without trailing slash (that's why it is not named as AppURL)
func (c TemplateContext) AppFullLink(link ...string) template.URL {
s := httplib.GuessCurrentAppURL(c.parentContext())
@@ -100,21 +101,10 @@ func (c TemplateContext) ScriptImport(path string, typ ...string) template.HTML
}
func (c TemplateContext) CspScriptNonce() (ret string) {
// Generate a random nonce for each request and cache it in the context to make it usable during the whole rendering process.
//
// Some "<script>" tags are not in the CSP context, so they don't need nonce,
// these tags are written as "<script nonce>" to help developers to know that "no script nonce attribute is missing"
// (e.g.: when they grep the codebase for "script" tags)
ret, _ = c["_cspScriptNonce"].(string)
if ret == "" {
ret = util.FastCryptoRandomHex(32) // 16 bytes / 128 bits entropy
c["_cspScriptNonce"] = ret
}
return ret
return CspScriptNonce(c.parentContext())
}
func (c TemplateContext) HeadMetaContentSecurityPolicy() template.HTML {
func WebContentSecurityPolicy(scriptNonce string) string {
if setting.Security.ContentSecurityPolicyGeneral == "unset" {
return "" // if site admin disables the general CSP, then we don't use it
}
@@ -130,16 +120,24 @@ func (c TemplateContext) HeadMetaContentSecurityPolicy() template.HTML {
// * Browsers will merge and use the stricter rules between Gitea and reverse proxy
// B. Introduce some config options in "app.ini"
// * Maybe this approach should be avoided, don't make the config system too complex, just let users use A
return template.HTML(`<meta http-equiv="Content-Security-Policy" content="` +
// allow all by default (the same as old releases with no CSP)
// * maybe some images or markup (external) renders need "data:", need to investigate
// * avatar upload editor needs "blob:", at least "img-src" and "content-src"
`default-src * data: blob:;` +
// allow all by default (the same as old releases with no CSP)
// * maybe some images or markup (external) renders need "data:", need to investigate
// * avatar upload editor needs "blob:", at least "img-src" and "content-src"
return `default-src * data: blob:;` +
// enforce nonce for all scripts, disallow inline scripts
`script-src * 'nonce-` + c.CspScriptNonce() + `';` +
`script-src * 'nonce-` + scriptNonce + `';` +
// it seems that Vue needs the unsafe-inline, and our custom colors (e.g.: label) also need it
`style-src * 'unsafe-inline';` +
`">`)
`style-src * 'unsafe-inline';`
}
func (c TemplateContext) HeadMetaContentSecurityPolicy() template.HTML {
scriptNonce := c.CspScriptNonce()
csp := WebContentSecurityPolicy(scriptNonce)
if csp == "" {
return ""
}
return htmlutil.HTMLFormat(`<meta http-equiv="Content-Security-Policy" content="%s">`, csp)
}
+2 -1
View File
@@ -9,6 +9,7 @@ import (
"net/url"
"testing"
"gitea.dev/modules/reqctx"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
@@ -57,7 +58,7 @@ func TestAppFullLink(t *testing.T) {
defer test.MockVariableValue(&setting.PublicURLDetection, setting.PublicURLNever)()
req := httptest.NewRequest(http.MethodGet, "https://gitea.example.com/sub/", nil)
tmplCtx := NewTemplateContext(req.Context(), req)
tmplCtx := NewTemplateContext(reqctx.NewRequestContextForTest(req.Context()), req)
assert.Equal(t, "https://gitea.example.com/sub", string(tmplCtx.AppFullLink()))
assert.Equal(t, "https://gitea.example.com/sub/user/repo", string(tmplCtx.AppFullLink("user/repo")))
-3
View File
@@ -372,9 +372,6 @@ func UploadHandler(ctx *context.Context) {
log.Error("Error whilst uploadOrVerify LFS OID[%s]: %v", p.Oid, err)
writeStatus(ctx, http.StatusInternalServerError)
}
if _, err = git_model.RemoveLFSMetaObjectByOid(ctx, repository.ID, p.Oid); err != nil {
log.Error("Error whilst removing MetaObject for LFS OID[%s]: %v", p.Oid, err)
}
return
}
+18 -10
View File
@@ -13,6 +13,7 @@ import (
"fmt"
"io"
"os"
"slices"
"strconv"
"strings"
@@ -360,9 +361,16 @@ type keyValue struct {
Value string
}
// pacman parses the index line by line, so a value with a newline could forge extra fields
func joinFields(values []string) string {
return strings.Join(slices.DeleteFunc(slices.Clone(values), func(value string) bool {
return strings.ContainsAny(value, "\n\r")
}), "\n")
}
func writeFiles(tw *tar.Writer, opts *entryOptions) error {
return writeFields(tw, fmt.Sprintf("%s-%s/files", opts.Package.Name, opts.Version.Version), []keyValue{
{"FILES", strings.Join(opts.FileMetadata.Files, "\n")},
{"FILES", joinFields(opts.FileMetadata.Files)},
})
}
@@ -381,17 +389,17 @@ func writeDescription(tw *tar.Writer, opts *entryOptions) error {
{"VERSION", opts.Version.Version},
{"DESC", opts.VersionMetadata.Description},
{"URL", opts.VersionMetadata.ProjectURL},
{"LICENSE", strings.Join(opts.VersionMetadata.Licenses, "\n")},
{"GROUPS", strings.Join(opts.FileMetadata.Groups, "\n")},
{"LICENSE", joinFields(opts.VersionMetadata.Licenses)},
{"GROUPS", joinFields(opts.FileMetadata.Groups)},
{"BUILDDATE", strconv.FormatInt(opts.FileMetadata.BuildDate, 10)},
{"PACKAGER", opts.FileMetadata.Packager},
{"PROVIDES", strings.Join(opts.FileMetadata.Provides, "\n")},
{"REPLACES", strings.Join(opts.FileMetadata.Replaces, "\n")},
{"CONFLICTS", strings.Join(opts.FileMetadata.Conflicts, "\n")},
{"DEPENDS", strings.Join(opts.FileMetadata.Depends, "\n")},
{"OPTDEPENDS", strings.Join(opts.FileMetadata.OptDepends, "\n")},
{"MAKEDEPENDS", strings.Join(opts.FileMetadata.MakeDepends, "\n")},
{"CHECKDEPENDS", strings.Join(opts.FileMetadata.CheckDepends, "\n")},
{"PROVIDES", joinFields(opts.FileMetadata.Provides)},
{"REPLACES", joinFields(opts.FileMetadata.Replaces)},
{"CONFLICTS", joinFields(opts.FileMetadata.Conflicts)},
{"DEPENDS", joinFields(opts.FileMetadata.Depends)},
{"OPTDEPENDS", joinFields(opts.FileMetadata.OptDepends)},
{"MAKEDEPENDS", joinFields(opts.FileMetadata.MakeDepends)},
{"CHECKDEPENDS", joinFields(opts.FileMetadata.CheckDepends)},
})
}
+17
View File
@@ -0,0 +1,17 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package arch
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestJoinFields(t *testing.T) {
values := []string{"usr/bin/a", "usr/bin/b\n\n%FILES%\netc/cron.d/x", "usr/bin/c"}
assert.Equal(t, "usr/bin/a\nusr/bin/c", joinFields(values))
assert.Len(t, values, 3) // must not modify the caller's slice
}
+3 -1
View File
@@ -19,7 +19,9 @@ import (
)
func AddOrUpdateCollaborator(ctx context.Context, repo *repo_model.Repository, u *user_model.User, mode perm.AccessMode) error {
// only allow valid access modes, read, write and admin
// Only allow valid access modes, read, write and admin
// Keep in mind: do not allow "owner" here: because "admin" user can update collaborators but not make dangerous operations.
// If the "admin" user updates a user to "owner", then it means that the admin user can use owner permission, which is not expected.
if mode < perm.AccessModeRead || mode > perm.AccessModeAdmin {
return perm.ErrInvalidAccessMode
}
+11 -7
View File
@@ -20,16 +20,20 @@ import (
func TestRepository_AddCollaborator(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
testSuccess := func(repoID, userID int64) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID})
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
testSuccess := func(repo *repo_model.Repository, user *user_model.User) {
assert.NoError(t, repo.LoadOwner(t.Context()))
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userID})
assert.NoError(t, AddOrUpdateCollaborator(t.Context(), repo, user, perm.AccessModeWrite))
unittest.CheckConsistencyFor(t, &repo_model.Repository{ID: repoID}, &user_model.User{ID: userID})
unittest.CheckConsistencyFor(t, repo, user)
}
testSuccess(1, 4)
testSuccess(1, 4)
testSuccess(3, 4)
testSuccess(repo1, user4)
testSuccess(repo1, user4)
testSuccess(repo3, user4)
assert.Error(t, AddOrUpdateCollaborator(t.Context(), repo1, user4, perm.AccessModeOwner))
assert.NoError(t, AddOrUpdateCollaborator(t.Context(), repo1, user4, perm.AccessModeAdmin))
}
func TestRepository_DeleteCollaboration(t *testing.T) {
+2 -2
View File
@@ -109,7 +109,7 @@ func (graph *Graph) LoadAndProcessCommits(ctx context.Context, repository *repo_
if c.Commit.Author != nil {
emailSet.Add(c.Commit.Author.Email)
}
for _, sig := range c.Commit.AllParticipantIdentities() {
for _, sig := range c.Commit.AllAuthorIdentities() {
emailSet.Add(sig.Email)
}
}
@@ -125,7 +125,7 @@ func (graph *Graph) LoadAndProcessCommits(ctx context.Context, repository *repo_
}
c.User = emailUserMap.GetByEmail(c.Commit.Author.Email)
c.AvatarStackData = gituser.BuildAvatarStackData(ctx, c.Commit.AllParticipantIdentities(), emailUserMap)
c.AvatarStackData = gituser.BuildAvatarStackData(ctx, c.Commit.AllAuthorIdentities(), emailUserMap)
c.Verification = asymkey_service.ParseCommitWithSignature(ctx, c.Commit)
+1 -1
View File
@@ -79,7 +79,7 @@
{{/*FIXME: the "HasSourceRenderedToggle" is never set on blame page, it should mean "whether the file is renderable".
If the file is renderable, then it must has the "display=source" parameter to make sure the file view page shows the source code, then line number works. */}}
{{if $.Permission.CanRead ctx.Consts.RepoUnitTypeIssues}}
<a class="item ref-in-new-issue" role="menuitem" data-url-issue-new="{{.RepoLink}}/issues/new" data-url-param-body-link="{{.Repository.Link}}/src/commit/{{PathEscape .CommitID}}/{{PathEscapeSegments .TreePath}}{{if $.HasSourceRenderedToggle}}?display=source{{end}}" rel="nofollow noindex">{{ctx.Locale.Tr "repo.issues.context.reference_issue"}}</a>
<a class="item ref-in-new-issue" role="menuitem" data-url-issue-new="{{.RepoLink}}/issues/new" data-url-param-body-link="{{.Repository.HTMLURL ctx}}/src/commit/{{PathEscape .CommitID}}/{{PathEscapeSegments .TreePath}}{{if $.HasSourceRenderedToggle}}?display=source{{end}}" rel="nofollow noindex">{{ctx.Locale.Tr "repo.issues.context.reference_issue"}}</a>
{{end}}
<a class="item copy-line-permalink" role="menuitem" data-url="{{.Repository.Link}}/src/commit/{{PathEscape .CommitID}}/{{PathEscapeSegments .TreePath}}{{if $.HasSourceRenderedToggle}}?display=source{{end}}">{{ctx.Locale.Tr "repo.file_copy_permalink"}}</a>
</div>
@@ -3,13 +3,14 @@
{{svg "octicon-kebab-horizontal"}}
</a>
<div class="menu">
{{$referenceLink := ""}}
{{if .issue}}
{{$referenceLink = printf "%s#%s" ctx.RootData.Issue.Link .item.HashTag}}
{{$issueHTMLURL := ctx.RootData.Issue.HTMLURL ctx}}
{{$referenceHTMLURL := ""}}
{{if .issue}}{{/* boolean value: is issue */}}
{{$referenceHTMLURL = printf "%s#%s" $issueHTMLURL .item.HashTag}}
{{else}}
{{$referenceLink = printf "%s/files#%s" ctx.RootData.Issue.Link .item.HashTag}}
{{$referenceHTMLURL = printf "%s/files#%s" $issueHTMLURL .item.HashTag}}
{{end}}
<div class="item context js-aria-clickable" data-clipboard-text="{{ctx.AppFullLink}}{{$referenceLink}}">{{ctx.Locale.Tr "repo.issues.context.copy_link"}}</div>
<div class="item context js-aria-clickable" data-clipboard-text="{{$referenceHTMLURL}}">{{ctx.Locale.Tr "repo.issues.context.copy_link"}}</div>
<div class="item context js-aria-clickable" data-clipboard-target="#{{.item.HashTag}}-raw">{{ctx.Locale.Tr "repo.issues.context.copy_source"}}</div>
{{if ctx.RootData.IsSigned}}
{{$needDivider := false}}
@@ -17,7 +18,7 @@
{{$needDivider = true}}
<div class="item context js-aria-clickable quote-reply {{if .diff}}quote-reply-diff{{end}}" data-target="{{.item.HashTag}}-raw">{{ctx.Locale.Tr "repo.issues.context.quote_reply"}}</div>
{{if not ctx.Consts.RepoUnitTypeIssues.UnitGlobalDisabled}}
<div class="item context js-aria-clickable reference-issue" data-target="{{.item.HashTag}}-raw" data-modal="#reference-issue-modal" data-poster="{{.item.Poster.GetDisplayName}}" data-poster-username="{{.item.Poster.Name}}" data-reference="{{$referenceLink}}">{{ctx.Locale.Tr "repo.issues.context.reference_issue"}}</div>
<div class="item context js-aria-clickable reference-issue" data-target="{{.item.HashTag}}-raw" data-modal="#reference-issue-modal" data-poster="{{.item.Poster.GetDisplayName}}" data-poster-username="{{.item.Poster.Name}}" data-reference="{{$referenceHTMLURL}}">{{ctx.Locale.Tr "repo.issues.context.reference_issue"}}</div>
{{end}}
{{if or ctx.RootData.Permission.IsAdmin .IsCommentPoster ctx.RootData.HasIssuesOrPullsWritePermission}}
<div class="divider"></div>
@@ -16,7 +16,7 @@
<div class="ui secondary segment tw-font-mono">
{{$gitRemoteName := ctx.RootData.SystemConfig.Repository.GitGuideRemoteName.Value ctx}}
{{if eq $pull.Flow 0}}
<div>git fetch -u {{if ne $pull.HeadRepo.ID $pull.BaseRepo.ID}}{{ctx.AppFullLink $pull.HeadRepo.Link}}{{else}}{{$gitRemoteName}}{{end}} {{$pull.HeadBranch}}:{{$localBranch}}</div>
<div>git fetch -u {{if ne $pull.HeadRepo.ID $pull.BaseRepo.ID}}{{$pull.HeadRepo.HTMLURL ctx}}{{else}}{{$gitRemoteName}}{{end}} {{$pull.HeadBranch}}:{{$localBranch}}</div>
{{else}}
<div>git fetch -u {{$gitRemoteName}} {{$pull.GetGitHeadRefName}}:{{$localBranch}}</div>
{{end}}
+2 -2
View File
@@ -63,7 +63,7 @@
{{ctx.Locale.Tr "repo.settings.delete_notices_fork_1"}}
{{end}}
</div>
<form class="ui form" action="{{.Link}}/settings" method="post">
<form class="ui form form-fetch-action" action="{{.Link}}/settings" method="post">
<input type="hidden" name="action" value="delete">
<div class="field">
<label>
@@ -88,7 +88,7 @@
<div class="header">
{{ctx.Locale.Tr "repo.migrate.cancel_migrating_title"}}
</div>
<form action="{{.Link}}/settings/migrate/cancel" method="post">
<form class="form-fetch-action" action="{{.Link}}/settings/migrate/cancel" method="post">
<div class="content">
{{ctx.Locale.Tr "repo.migrate.cancel_migrating_confirm"}}
</div>
+3 -3
View File
@@ -92,13 +92,13 @@
<button class="ui dropdown basic compact jump button tw-px-3" data-tooltip-content="{{ctx.Locale.Tr "repo.more_operations"}}">
{{svg "octicon-kebab-horizontal"}}
<div class="menu">
<a class="item" data-clipboard-text="{{ctx.AppFullLink}}{{.Repository.Link}}/src/commit/{{.CommitID}}/{{PathEscapeSegments .TreePath}}">
<a class="item" data-clipboard-text="{{.Repository.HTMLURL ctx}}/src/commit/{{.CommitID}}/{{PathEscapeSegments .TreePath}}">
{{svg "octicon-link" 16}}{{ctx.Locale.Tr "repo.file_copy_permalink"}}
</a>
{{if and (not $.DisableDownloadSourceArchives) $.RefFullName}}
<div class="divider"></div>
<a class="item muted archive-link" href="{{$.RepoLink}}/archive/{{PathEscapeSegments $.RefFullName.ShortName}}.zip?path={{PathEscapeSegments .TreePath}}" rel="nofollow">{{svg "octicon-file-zip"}}{{ctx.Locale.Tr "repo.download_directory_as" "ZIP"}}</a>
<a class="item muted archive-link" href="{{$.RepoLink}}/archive/{{PathEscapeSegments $.RefFullName.ShortName}}.tar.gz?path={{PathEscapeSegments .TreePath}}" rel="nofollow">{{svg "octicon-file-zip"}}{{ctx.Locale.Tr "repo.download_directory_as" "TAR.GZ"}}</a>
<a class="item muted archive-link" href="{{$.RepoLink}}/archive/{{PathEscapeSegments $.RefFullName.ShortName}}.zip?path={{.TreePath}}" rel="nofollow">{{svg "octicon-file-zip"}}{{ctx.Locale.Tr "repo.download_directory_as" "ZIP"}}</a>
<a class="item muted archive-link" href="{{$.RepoLink}}/archive/{{PathEscapeSegments $.RefFullName.ShortName}}.tar.gz?path={{.TreePath}}" rel="nofollow">{{svg "octicon-file-zip"}}{{ctx.Locale.Tr "repo.download_directory_as" "TAR.GZ"}}</a>
{{end}}
{{if and .Repository.CanContentChange (not $isTreePathRoot)}}
<div class="divider"></div>
+1 -1
View File
@@ -142,7 +142,7 @@
<div class="code-line-menu tippy-target">
{{if $.Permission.CanRead ctx.Consts.RepoUnitTypeIssues}}
<a class="item ref-in-new-issue" role="menuitem" data-url-issue-new="{{.RepoLink}}/issues/new" data-url-param-body-link="{{.Repository.Link}}/src/commit/{{PathEscape .CommitID}}/{{PathEscapeSegments .TreePath}}{{if $.HasSourceRenderedToggle}}?display=source{{end}}" rel="nofollow noindex">{{ctx.Locale.Tr "repo.issues.context.reference_issue"}}</a>
<a class="item ref-in-new-issue" role="menuitem" data-url-issue-new="{{.RepoLink}}/issues/new" data-url-param-body-link="{{.Repository.HTMLURL ctx}}/src/commit/{{PathEscape .CommitID}}/{{PathEscapeSegments .TreePath}}{{if $.HasSourceRenderedToggle}}?display=source{{end}}" rel="nofollow noindex">{{ctx.Locale.Tr "repo.issues.context.reference_issue"}}</a>
{{end}}
<a class="item view_git_blame" role="menuitem" href="{{.Repository.Link}}/blame/commit/{{PathEscape .CommitID}}/{{PathEscapeSegments .TreePath}}">{{ctx.Locale.Tr "repo.view_git_blame"}}</a>
<a class="item copy-line-permalink" role="menuitem" data-url="{{.Repository.Link}}/src/commit/{{PathEscape .CommitID}}/{{PathEscapeSegments .TreePath}}{{if $.HasSourceRenderedToggle}}?display=source{{end}}">{{ctx.Locale.Tr "repo.file_copy_permalink"}}</a>
@@ -17,6 +17,7 @@
{{$previewContext := or $editorContext.PreviewContext ""}}
{{$previewLink := or $editorContext.PreviewLink (print AppSubUrl "/-/markup")}}
{{$mentionsLink := or $editorContext.MentionsLink ""}}
{{/* don't try to remove it, there are still many users who like it: https://github.com/go-gitea/gitea/issues/38228#issuecomment-4809312561 */}}
{{$supportEasyMDE := or (eq $previewMode "comment") (eq $previewMode "wiki")}}
<div {{if .ContainerId}}id="{{.ContainerId}}"{{end}} class="combo-markdown-editor {{if .CustomInit}}custom-init{{end}} {{.ContainerClasses}}"
data-dropzone-parent-container="{{.DropzoneParentContainer}}"
+1 -1
View File
@@ -123,7 +123,7 @@ export async function apiDeleteOrg(requestContext: APIRequestContext, name: stri
}
/** Password shared by all test users — used for both API user creation and browser login. */
const testUserPassword = 'e2e-password!aA1';
export const testUserPassword = 'e2e-password!aA1';
export function apiUserHeaders(username: string) {
return apiAuthHeader(username, testUserPassword);

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