mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-12 14:34:23 +09:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51938de973 | ||
|
|
21fda8f5be | ||
|
|
9ab9c18919 | ||
|
|
e2a0a87ae0 | ||
|
|
92044649a0 | ||
|
|
94011d2850 | ||
|
|
fe252be0ae | ||
|
|
cca0c65a6c | ||
|
|
6387c8ba6e | ||
|
|
6eab271921 | ||
|
|
eab225f095 |
+75
-47
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+1
-8
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": {}
|
||||
@@ -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>
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Generated
+12
-12
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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), "", "")
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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) {
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")))
|
||||
|
||||
@@ -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)},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import {test, expect, type Page} from '@playwright/test';
|
||||
import {apiCreateUser, loginUser, randomString, testUserPassword} from './utils.ts';
|
||||
|
||||
const signedIn = /^(?!.*\/user\/(login|webauthn))/; // the target of a finished login varies
|
||||
|
||||
async function registerKey(page: Page, nickname: string) {
|
||||
await page.goto('/user/settings/security');
|
||||
await page.getByLabel('Nickname').fill(nickname);
|
||||
await page.getByRole('button', {name: 'Add Security Key'}).click();
|
||||
}
|
||||
|
||||
async function signInWithPassword(page: Page, username: string) {
|
||||
await page.context().clearCookies();
|
||||
await page.goto('/user/login');
|
||||
await page.getByLabel('Username or Email Address').fill(username);
|
||||
await page.getByLabel('Password').fill(testUserPassword);
|
||||
await page.getByRole('button', {name: 'Sign In'}).click();
|
||||
}
|
||||
|
||||
// regression: credProtect level 3 hid the credential from the second-factor login
|
||||
test('security key survives credProtect', async ({page, request, browserName}) => {
|
||||
test.skip(browserName !== 'chromium', 'only the CDP authenticator emulates credProtect'); // eslint-disable-line playwright/no-skipped-test
|
||||
|
||||
const username = `e2e-credprotect-${randomString(8)}`;
|
||||
await apiCreateUser(request, username);
|
||||
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send('WebAuthn.enable');
|
||||
await cdp.send('WebAuthn.addVirtualAuthenticator', {options: {
|
||||
protocol: 'ctap2',
|
||||
ctap2Version: 'ctap2_1',
|
||||
transport: 'usb',
|
||||
hasResidentKey: true,
|
||||
hasUserVerification: true,
|
||||
hasCredBlob: true, // CDP only emulates credProtect together with credBlob
|
||||
isUserVerified: true,
|
||||
}});
|
||||
|
||||
await loginUser(page, username);
|
||||
await registerKey(page, 'e2e-key');
|
||||
await expect(page.getByText('e2e-key')).toBeVisible();
|
||||
|
||||
await registerKey(page, 'e2e-key-again');
|
||||
await expect(page.locator('#webauthn-error-msg')).toContainText('already registered');
|
||||
|
||||
await signInWithPassword(page, username);
|
||||
await expect(page).toHaveURL(signedIn);
|
||||
});
|
||||
|
||||
// this authenticator has no credProtect, so it cannot replace the test above
|
||||
test('security key signs in as second factor and as passkey', async ({page, request}) => {
|
||||
const username = `e2e-passkey-${randomString(8)}`;
|
||||
await apiCreateUser(request, username);
|
||||
await page.context().credentials.install();
|
||||
|
||||
await loginUser(page, username);
|
||||
await registerKey(page, 'e2e-key');
|
||||
await expect(page.getByText('e2e-key')).toBeVisible();
|
||||
|
||||
await signInWithPassword(page, username);
|
||||
await expect(page).toHaveURL(signedIn);
|
||||
|
||||
await page.context().clearCookies();
|
||||
await page.goto('/user/login');
|
||||
await page.getByText('Sign in with a passkey').click();
|
||||
await expect(page).toHaveURL(signedIn);
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"gitea.dev/services/auth/source/oauth2"
|
||||
"gitea.dev/tests"
|
||||
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -570,3 +571,65 @@ func TestOAuth2AutoLinkWithTwoFactor(t *testing.T) {
|
||||
|
||||
session.MakeRequest(t, NewRequest(t, "GET", "/user/settings"), http.StatusOK)
|
||||
}
|
||||
|
||||
// a security key must be challenged on every path that issues a session, not just the password login
|
||||
func TestWebAuthnSecondFactorRequired(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
newWebAuthnUser := func(t *testing.T, name string) *user_model.User {
|
||||
u := &user_model.User{Name: name, Email: name + "@example.com"}
|
||||
require.NoError(t, user_model.CreateUser(t.Context(), u, &user_model.Meta{}))
|
||||
_, err := auth_model.CreateCredential(t.Context(), u.ID, "test-key", &webauthn.Credential{ID: []byte(name)})
|
||||
require.NoError(t, err)
|
||||
return u
|
||||
}
|
||||
|
||||
assertOAuth2Challenged := func(t *testing.T, sourceName string) {
|
||||
session := emptyTestSession(t)
|
||||
resp := session.MakeRequest(t, NewRequest(t, "GET", "/user/oauth2/"+sourceName), http.StatusTemporaryRedirect)
|
||||
u, err := url.Parse(resp.Header().Get("Location"))
|
||||
require.NoError(t, err)
|
||||
state := u.Query().Get("state")
|
||||
require.NotEmpty(t, state)
|
||||
|
||||
callbackURL := fmt.Sprintf("/user/oauth2/%s/callback?code=test-code&state=%s", sourceName, url.QueryEscape(state))
|
||||
resp = session.MakeRequest(t, NewRequest(t, "GET", callbackURL), http.StatusSeeOther)
|
||||
assert.Contains(t, resp.Header().Get("Location"), "/user/webauthn")
|
||||
session.MakeRequest(t, NewRequest(t, "GET", "/user/settings"), http.StatusSeeOther) // the redirect alone does not prove no session was issued
|
||||
}
|
||||
|
||||
t.Run("OAuth2AutoLink", func(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.OAuth2Client.EnableAutoRegistration, true)()
|
||||
defer test.MockVariableValue(&setting.OAuth2Client.AccountLinking, setting.OAuth2AccountLinkingAuto)()
|
||||
defer test.MockVariableValue(&setting.OAuth2Client.Username, setting.OAuth2UsernameEmail)()
|
||||
|
||||
const sourceName, sub = "oauth-autolink-webauthn", "autolink-sub"
|
||||
u := newWebAuthnUser(t, "autolink-webauthn")
|
||||
srv := newFakeOIDCServer(t, FakeOIDCConfig{Sub: sub, Email: u.Email, Name: u.Name})
|
||||
addOAuth2Source(t, sourceName, newOIDCSource(srv, false, false))
|
||||
assertOAuth2Challenged(t, sourceName)
|
||||
})
|
||||
|
||||
t.Run("OAuth2LinkedIdentity", func(t *testing.T) {
|
||||
const sourceName, sub = "oauth-signin-webauthn", "signin-sub"
|
||||
u := newWebAuthnUser(t, "signin-webauthn")
|
||||
srv := newFakeOIDCServer(t, FakeOIDCConfig{Sub: sub, Email: u.Email, Name: u.Name})
|
||||
addOAuth2Source(t, sourceName, newOIDCSource(srv, false, false))
|
||||
authSource, err := auth_model.GetActiveOAuth2SourceByAuthName(t.Context(), sourceName)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, user_model.LinkExternalToUser(t.Context(), u, &user_model.ExternalLoginUser{
|
||||
ExternalID: sub, UserID: u.ID, LoginSourceID: authSource.ID, Provider: "openidConnect",
|
||||
}))
|
||||
assertOAuth2Challenged(t, sourceName)
|
||||
})
|
||||
|
||||
t.Run("PasswordReset", func(t *testing.T) {
|
||||
u := newWebAuthnUser(t, "reset-webauthn")
|
||||
code := user_model.GenerateUserTimeLimitCode(&user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeResetPassword}, u)
|
||||
session := emptyTestSession(t)
|
||||
req := NewRequestWithValues(t, "POST", "/user/recover_account", map[string]string{"code": code, "password": "new-Password!1"})
|
||||
resp := session.MakeRequest(t, req, http.StatusSeeOther)
|
||||
assert.Contains(t, resp.Header().Get("Location"), "/user/webauthn")
|
||||
session.MakeRequest(t, NewRequest(t, "GET", "/user/settings"), http.StatusSeeOther)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -127,7 +127,8 @@ func TestExternalMarkupRenderer(t *testing.T) {
|
||||
req = NewRequest(t, "GET", "/user2/repo1/render/branch/master/bin.no-sanitizer")
|
||||
respSub := MakeRequest(t, req, http.StatusOK)
|
||||
assert.Equal(t, binaryContent, respSub.Body.String()) // raw content should keep the raw bytes (including invalid UTF-8 bytes), and no "external-render-iframe" helpers
|
||||
assert.Empty(t, respSub.Header().Get("Content-Security-Policy"), "sandbox is disabled by RENDER_CONTENT_SANDBOX")
|
||||
assert.NotContains(t, respSub.Header().Get("Content-Security-Policy"), "sandbox", "sandbox is disabled by RENDER_CONTENT_SANDBOX")
|
||||
assert.Contains(t, respSub.Header().Get("Content-Security-Policy"), "nonce-", "it should have the general policies as a normal web page")
|
||||
})
|
||||
|
||||
t.Run("HTMLContentWithExternalRenderIframeHelper", func(t *testing.T) {
|
||||
@@ -139,7 +140,8 @@ func TestExternalMarkupRenderer(t *testing.T) {
|
||||
`<script>foo("raw")</script>`,
|
||||
respSub.Body.String(),
|
||||
)
|
||||
assert.Empty(t, respSub.Header().Get("Content-Security-Policy"))
|
||||
assert.NotContains(t, respSub.Header().Get("Content-Security-Policy"), "sandbox")
|
||||
assert.Contains(t, respSub.Header().Get("Content-Security-Policy"), "nonce-")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/tests"
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// one credential serves both logins, so their user verification is coupled
|
||||
func TestWebAuthnUserVerification(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
session := loginUser(t, "user2")
|
||||
req := NewRequestWithValues(t, "POST", "/user/settings/security/webauthn/request_register", map[string]string{"name": "test-key"})
|
||||
creation := DecodeJSON(t, session.MakeRequest(t, req, http.StatusOK), &protocol.CredentialCreation{})
|
||||
assert.Equal(t, protocol.VerificationRequired, creation.Response.AuthenticatorSelection.UserVerification)
|
||||
|
||||
session = loginUserWithPassword(t, "user32", "notpassword") // user32 has a webauthn credential
|
||||
req = NewRequest(t, "GET", "/user/webauthn/assertion")
|
||||
secondFactor := DecodeJSON(t, session.MakeRequest(t, req, http.StatusOK), &protocol.CredentialAssertion{})
|
||||
assert.Equal(t, protocol.VerificationPreferred, secondFactor.Response.UserVerification)
|
||||
|
||||
session = emptyTestSession(t)
|
||||
req = NewRequest(t, "GET", "/user/webauthn/passkey/assertion") // also seeds the session for the request below
|
||||
passkey := DecodeJSON(t, session.MakeRequest(t, req, http.StatusOK), &protocol.CredentialAssertion{})
|
||||
assert.Equal(t, protocol.VerificationRequired, passkey.Response.UserVerification)
|
||||
|
||||
// a malformed response used to dereference a nil user
|
||||
req = NewRequestWithJSON(t, "POST", "/user/webauthn/passkey/login", map[string]string{"bogus": "1"})
|
||||
session.MakeRequest(t, req, http.StatusForbidden)
|
||||
}
|
||||
@@ -273,12 +273,10 @@ a {
|
||||
color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
text-decoration-line: none;
|
||||
text-decoration-skip-ink: all;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration-line: underline;
|
||||
text-underline-position: under; /* necessary for CJK fonts, otherwise, default "auto" makes the underline cross-over the CJK text bottom */
|
||||
}
|
||||
|
||||
/* a = always colored, underlined on hover */
|
||||
|
||||
@@ -263,6 +263,11 @@ async function webAuthnRegisterRequest() {
|
||||
});
|
||||
await webauthnRegistered(credential);
|
||||
} catch (err) {
|
||||
// an already registered authenticator raises this
|
||||
if (err instanceof DOMException && err.name === 'InvalidStateError') {
|
||||
webAuthnError('duplicated');
|
||||
return;
|
||||
}
|
||||
webAuthnError('unknown', errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user