From 3eecde3f3318eb6f600f651442e754d8f6691f03 Mon Sep 17 00:00:00 2001 From: Zettat123 Date: Fri, 19 May 2023 17:20:18 +0800 Subject: [PATCH 01/27] Change `add_on` in `keys_ssh.tmpl` (#24803) Follow #24562 --- templates/user/settings/keys_ssh.tmpl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/user/settings/keys_ssh.tmpl b/templates/user/settings/keys_ssh.tmpl index b761d9093d9f5..f6ea508cc8c32 100644 --- a/templates/user/settings/keys_ssh.tmpl +++ b/templates/user/settings/keys_ssh.tmpl @@ -59,7 +59,7 @@ {{.Fingerprint}}
- {{$.locale.Tr "settings.add_on" (DateTime "short" .CreatedUnix) | Safe}} — {{svg "octicon-info"}} {{if .HasUsed}}{{$.locale.Tr "settings.last_used"}} {{DateTime "short" .UpdatedUnix}}{{else}}{{$.locale.Tr "settings.no_activity"}}{{end}} + {{$.locale.Tr "settings.added_on" (DateTime "short" .CreatedUnix) | Safe}} — {{svg "octicon-info"}} {{if .HasUsed}}{{$.locale.Tr "settings.last_used"}} {{DateTime "short" .UpdatedUnix}}{{else}}{{$.locale.Tr "settings.no_activity"}}{{end}}
From 7985cde84df5ee93bfb37b20681d69e67d3f32fc Mon Sep 17 00:00:00 2001 From: Jason Song Date: Fri, 19 May 2023 19:35:12 +0800 Subject: [PATCH 02/27] Fix Actions being enabled accidentally (#24802) Regression of #24536. If the user doesn't explicitly disable Actions, it will be enabled. 1. Gitea will call `loadRepositoryFrom` before `loadActionsFrom`. https://github.com/go-gitea/gitea/blob/25d4f95df25dae5226e96e813dde87b071d9155e/modules/setting/setting.go#L234-L237 2. In `loadRepositoryFrom`, `rootCfg.Section("actions").Key("ENABLED").MustBool(true)` will set `actions.ENABLED` with `true`. https://github.com/go-gitea/gitea/blob/25d4f95df25dae5226e96e813dde87b071d9155e/modules/setting/repository.go#L313-L315 3. In `loadActionsFrom`, `rootCfg.Section("actions")` will get a section with Actions enabled. https://github.com/go-gitea/gitea/blob/25d4f95df25dae5226e96e813dde87b071d9155e/modules/setting/actions.go#L23-L26 Although the cause of the problem was using `true` by copy-paste mistake, it also surprised me that **`rootCfg.Section("actions").Key("ENABLED").MustBool(true)` doesn't only read, but also write.** --- modules/setting/repository.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/setting/repository.go b/modules/setting/repository.go index 153307a0b63da..900b56cc52b33 100644 --- a/modules/setting/repository.go +++ b/modules/setting/repository.go @@ -306,11 +306,11 @@ func loadRepositoryFrom(rootCfg ConfigProvider) { log.Fatal("Failed to map Repository.PullRequest settings: %v", err) } - if !rootCfg.Section("packages").Key("ENABLED").MustBool(true) { + if !rootCfg.Section("packages").Key("ENABLED").MustBool(Packages.Enabled) { Repository.DisabledRepoUnits = append(Repository.DisabledRepoUnits, "repo.packages") } - if !rootCfg.Section("actions").Key("ENABLED").MustBool(true) { + if !rootCfg.Section("actions").Key("ENABLED").MustBool(Actions.Enabled) { Repository.DisabledRepoUnits = append(Repository.DisabledRepoUnits, "repo.actions") } From c757765a9e5c2d4f73b1a7c3debe3548c735bd54 Mon Sep 17 00:00:00 2001 From: FuXiaoHei Date: Fri, 19 May 2023 21:37:57 +0800 Subject: [PATCH 03/27] Implement actions artifacts (#22738) Implement action artifacts server api. This change is used for supporting https://github.com/actions/upload-artifact and https://github.com/actions/download-artifact in gitea actions. It can run sample workflow from doc https://docs.github.com/en/actions/using-workflows/storing-workflow-data-as-artifacts. The api design is inspired by https://github.com/nektos/act/blob/master/pkg/artifacts/server.go and includes some changes from gitea internal structs and methods. Actions artifacts contains two parts: - Gitea server api and storage (this pr implement basic design without some complex cases supports) - Runner communicate with gitea server api (in comming) Old pr https://github.com/go-gitea/gitea/pull/22345 is outdated after actions merged. I create new pr from main branch. ![897f7694-3e0f-4f7c-bb4b-9936624ead45](https://user-images.githubusercontent.com/2142787/219382371-eb3cf810-e4e0-456b-a8ff-aecc2b1a1032.jpeg) Add artifacts list in actions workflow page. --- models/actions/artifact.go | 122 ++++ models/fixtures/access_token.yml | 2 +- models/fixtures/action_run.yml | 19 + models/fixtures/action_run_job.yml | 14 + models/fixtures/action_task.yml | 20 + models/migrations/migrations.go | 2 + models/migrations/v1_20/v257.go | 33 + models/repo.go | 15 + models/unittest/testdb.go | 2 +- modules/setting/actions.go | 9 +- modules/storage/storage.go | 11 +- options/locale/locale_en-US.ini | 2 + routers/api/actions/artifacts.go | 587 ++++++++++++++++++ routers/init.go | 6 + routers/web/repo/actions/view.go | 78 +++ routers/web/web.go | 2 + templates/repo/actions/view.tmpl | 1 + .../integration/api_actions_artifact_test.go | 143 +++++ tests/mssql.ini.tmpl | 3 + tests/mysql.ini.tmpl | 3 + tests/mysql8.ini.tmpl | 3 + tests/pgsql.ini.tmpl | 3 + tests/sqlite.ini.tmpl | 3 + web_src/js/components/RepoActionView.vue | 50 ++ 24 files changed, 1127 insertions(+), 6 deletions(-) create mode 100644 models/actions/artifact.go create mode 100644 models/fixtures/action_run.yml create mode 100644 models/fixtures/action_run_job.yml create mode 100644 models/fixtures/action_task.yml create mode 100644 models/migrations/v1_20/v257.go create mode 100644 routers/api/actions/artifacts.go create mode 100644 tests/integration/api_actions_artifact_test.go diff --git a/models/actions/artifact.go b/models/actions/artifact.go new file mode 100644 index 0000000000000..1b45fce0673bc --- /dev/null +++ b/models/actions/artifact.go @@ -0,0 +1,122 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +// This artifact server is inspired by https://github.com/nektos/act/blob/master/pkg/artifacts/server.go. +// It updates url setting and uses ObjectStore to handle artifacts persistence. + +package actions + +import ( + "context" + "errors" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" +) + +const ( + // ArtifactStatusUploadPending is the status of an artifact upload that is pending + ArtifactStatusUploadPending = 1 + // ArtifactStatusUploadConfirmed is the status of an artifact upload that is confirmed + ArtifactStatusUploadConfirmed = 2 + // ArtifactStatusUploadError is the status of an artifact upload that is errored + ArtifactStatusUploadError = 3 +) + +func init() { + db.RegisterModel(new(ActionArtifact)) +} + +// ActionArtifact is a file that is stored in the artifact storage. +type ActionArtifact struct { + ID int64 `xorm:"pk autoincr"` + RunID int64 `xorm:"index UNIQUE(runid_name)"` // The run id of the artifact + RunnerID int64 + RepoID int64 `xorm:"index"` + OwnerID int64 + CommitSHA string + StoragePath string // The path to the artifact in the storage + FileSize int64 // The size of the artifact in bytes + FileCompressedSize int64 // The size of the artifact in bytes after gzip compression + ContentEncoding string // The content encoding of the artifact + ArtifactPath string // The path to the artifact when runner uploads it + ArtifactName string `xorm:"UNIQUE(runid_name)"` // The name of the artifact when runner uploads it + Status int64 `xorm:"index"` // The status of the artifact, uploading, expired or need-delete + CreatedUnix timeutil.TimeStamp `xorm:"created"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated index"` +} + +// CreateArtifact create a new artifact with task info or get same named artifact in the same run +func CreateArtifact(ctx context.Context, t *ActionTask, artifactName string) (*ActionArtifact, error) { + if err := t.LoadJob(ctx); err != nil { + return nil, err + } + artifact, err := getArtifactByArtifactName(ctx, t.Job.RunID, artifactName) + if errors.Is(err, util.ErrNotExist) { + artifact := &ActionArtifact{ + RunID: t.Job.RunID, + RunnerID: t.RunnerID, + RepoID: t.RepoID, + OwnerID: t.OwnerID, + CommitSHA: t.CommitSHA, + Status: ArtifactStatusUploadPending, + } + if _, err := db.GetEngine(ctx).Insert(artifact); err != nil { + return nil, err + } + return artifact, nil + } else if err != nil { + return nil, err + } + return artifact, nil +} + +func getArtifactByArtifactName(ctx context.Context, runID int64, name string) (*ActionArtifact, error) { + var art ActionArtifact + has, err := db.GetEngine(ctx).Where("run_id = ? AND artifact_name = ?", runID, name).Get(&art) + if err != nil { + return nil, err + } else if !has { + return nil, util.ErrNotExist + } + return &art, nil +} + +// GetArtifactByID returns an artifact by id +func GetArtifactByID(ctx context.Context, id int64) (*ActionArtifact, error) { + var art ActionArtifact + has, err := db.GetEngine(ctx).ID(id).Get(&art) + if err != nil { + return nil, err + } else if !has { + return nil, util.ErrNotExist + } + + return &art, nil +} + +// UpdateArtifactByID updates an artifact by id +func UpdateArtifactByID(ctx context.Context, id int64, art *ActionArtifact) error { + art.ID = id + _, err := db.GetEngine(ctx).ID(id).AllCols().Update(art) + return err +} + +// ListArtifactsByRunID returns all artifacts of a run +func ListArtifactsByRunID(ctx context.Context, runID int64) ([]*ActionArtifact, error) { + arts := make([]*ActionArtifact, 0, 10) + return arts, db.GetEngine(ctx).Where("run_id=?", runID).Find(&arts) +} + +// ListUploadedArtifactsByRunID returns all uploaded artifacts of a run +func ListUploadedArtifactsByRunID(ctx context.Context, runID int64) ([]*ActionArtifact, error) { + arts := make([]*ActionArtifact, 0, 10) + return arts, db.GetEngine(ctx).Where("run_id=? AND status=?", runID, ArtifactStatusUploadConfirmed).Find(&arts) +} + +// ListArtifactsByRepoID returns all artifacts of a repo +func ListArtifactsByRepoID(ctx context.Context, repoID int64) ([]*ActionArtifact, error) { + arts := make([]*ActionArtifact, 0, 10) + return arts, db.GetEngine(ctx).Where("repo_id=?", repoID).Find(&arts) +} diff --git a/models/fixtures/access_token.yml b/models/fixtures/access_token.yml index 5ff5d66f662ed..791b3da1c459f 100644 --- a/models/fixtures/access_token.yml +++ b/models/fixtures/access_token.yml @@ -30,4 +30,4 @@ token_last_eight: 69d28c91 created_unix: 946687980 updated_unix: 946687980 -#commented out tokens so you can see what they are in plaintext \ No newline at end of file +#commented out tokens so you can see what they are in plaintext diff --git a/models/fixtures/action_run.yml b/models/fixtures/action_run.yml new file mode 100644 index 0000000000000..2c2151f354af6 --- /dev/null +++ b/models/fixtures/action_run.yml @@ -0,0 +1,19 @@ +- + id: 791 + title: "update actions" + repo_id: 4 + owner_id: 1 + workflow_id: "artifact.yaml" + index: 187 + trigger_user_id: 1 + ref: "refs/heads/master" + commit_sha: "c2d72f548424103f01ee1dc02889c1e2bff816b0" + event: "push" + is_fork_pull_request: 0 + status: 1 + started: 1683636528 + stopped: 1683636626 + created: 1683636108 + updated: 1683636626 + need_approval: 0 + approved_by: 0 diff --git a/models/fixtures/action_run_job.yml b/models/fixtures/action_run_job.yml new file mode 100644 index 0000000000000..071998b9796f9 --- /dev/null +++ b/models/fixtures/action_run_job.yml @@ -0,0 +1,14 @@ +- + id: 192 + run_id: 791 + repo_id: 4 + owner_id: 1 + commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0 + is_fork_pull_request: 0 + name: job_2 + attempt: 1 + job_id: job_2 + task_id: 47 + status: 1 + started: 1683636528 + stopped: 1683636626 diff --git a/models/fixtures/action_task.yml b/models/fixtures/action_task.yml new file mode 100644 index 0000000000000..c78fb3c5d6377 --- /dev/null +++ b/models/fixtures/action_task.yml @@ -0,0 +1,20 @@ +- + id: 47 + job_id: 192 + attempt: 3 + runner_id: 1 + status: 6 # 6 is the status code for "running", running task can upload artifacts + started: 1683636528 + stopped: 1683636626 + repo_id: 4 + owner_id: 1 + commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0 + is_fork_pull_request: 0 + token_hash: 6d8ef48297195edcc8e22c70b3020eaa06c52976db67d39b4260c64a69a2cc1508825121b7b8394e48e00b1bf8718b2a867e + token_salt: jVuKnSPGgy + token_last_eight: eeb1a71a + log_filename: artifact-test2/2f/47.log + log_in_storage: 1 + log_length: 707 + log_size: 90179 + log_expired: 0 diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 0e84ae9f0e29a..49bc0be4e50fc 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -491,6 +491,8 @@ var migrations = []Migration{ NewMigration("Add ArchivedUnix Column", v1_20.AddArchivedUnixToRepository), // v256 -> v257 NewMigration("Add is_internal column to package", v1_20.AddIsInternalColumnToPackage), + // v257 -> v258 + NewMigration("Add Actions Artifact table", v1_20.CreateActionArtifactTable), } // GetCurrentDBVersion returns the current db version diff --git a/models/migrations/v1_20/v257.go b/models/migrations/v1_20/v257.go new file mode 100644 index 0000000000000..6c6ca4c7486d0 --- /dev/null +++ b/models/migrations/v1_20/v257.go @@ -0,0 +1,33 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_20 //nolint + +import ( + "code.gitea.io/gitea/modules/timeutil" + + "xorm.io/xorm" +) + +func CreateActionArtifactTable(x *xorm.Engine) error { + // ActionArtifact is a file that is stored in the artifact storage. + type ActionArtifact struct { + ID int64 `xorm:"pk autoincr"` + RunID int64 `xorm:"index UNIQUE(runid_name)"` // The run id of the artifact + RunnerID int64 + RepoID int64 `xorm:"index"` + OwnerID int64 + CommitSHA string + StoragePath string // The path to the artifact in the storage + FileSize int64 // The size of the artifact in bytes + FileCompressedSize int64 // The size of the artifact in bytes after gzip compression + ContentEncoding string // The content encoding of the artifact + ArtifactPath string // The path to the artifact when runner uploads it + ArtifactName string `xorm:"UNIQUE(runid_name)"` // The name of the artifact when runner uploads it + Status int64 `xorm:"index"` // The status of the artifact + CreatedUnix timeutil.TimeStamp `xorm:"created"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated index"` + } + + return x.Sync(new(ActionArtifact)) +} diff --git a/models/repo.go b/models/repo.go index 5903132862362..2e0e8af16c4a5 100644 --- a/models/repo.go +++ b/models/repo.go @@ -59,6 +59,12 @@ func DeleteRepository(doer *user_model.User, uid, repoID int64) error { return fmt.Errorf("find actions tasks of repo %v: %w", repoID, err) } + // Query the artifacts of this repo, they will be needed after they have been deleted to remove artifacts files in ObjectStorage + artifacts, err := actions_model.ListArtifactsByRepoID(ctx, repoID) + if err != nil { + return fmt.Errorf("list actions artifacts of repo %v: %w", repoID, err) + } + // In case is a organization. org, err := user_model.GetUserByID(ctx, uid) if err != nil { @@ -164,6 +170,7 @@ func DeleteRepository(doer *user_model.User, uid, repoID int64) error { &actions_model.ActionRunJob{RepoID: repoID}, &actions_model.ActionRun{RepoID: repoID}, &actions_model.ActionRunner{RepoID: repoID}, + &actions_model.ActionArtifact{RepoID: repoID}, ); err != nil { return fmt.Errorf("deleteBeans: %w", err) } @@ -336,6 +343,14 @@ func DeleteRepository(doer *user_model.User, uid, repoID int64) error { } } + // delete actions artifacts in ObjectStorage after the repo have already been deleted + for _, art := range artifacts { + if err := storage.ActionsArtifacts.Delete(art.StoragePath); err != nil { + log.Error("remove artifact file %q: %v", art.StoragePath, err) + // go on + } + } + return nil } diff --git a/models/unittest/testdb.go b/models/unittest/testdb.go index 10a70ad9f8e8f..5351ff1139989 100644 --- a/models/unittest/testdb.go +++ b/models/unittest/testdb.go @@ -126,7 +126,7 @@ func MainTest(m *testing.M, testOpts *TestOptions) { setting.Packages.Storage.Path = filepath.Join(setting.AppDataPath, "packages") - setting.Actions.Storage.Path = filepath.Join(setting.AppDataPath, "actions_log") + setting.Actions.LogStorage.Path = filepath.Join(setting.AppDataPath, "actions_log") setting.Git.HomePath = filepath.Join(setting.AppDataPath, "home") diff --git a/modules/setting/actions.go b/modules/setting/actions.go index b11500dab4a44..eb1b637a1c734 100644 --- a/modules/setting/actions.go +++ b/modules/setting/actions.go @@ -10,7 +10,8 @@ import ( // Actions settings var ( Actions = struct { - Storage // how the created logs should be stored + LogStorage Storage // how the created logs should be stored + ArtifactStorage Storage // how the created artifacts should be stored Enabled bool DefaultActionsURL string `ini:"DEFAULT_ACTIONS_URL"` }{ @@ -25,5 +26,9 @@ func loadActionsFrom(rootCfg ConfigProvider) { log.Fatal("Failed to map Actions settings: %v", err) } - Actions.Storage = getStorage(rootCfg, "actions_log", "", nil) + actionsSec := rootCfg.Section("actions.artifacts") + storageType := actionsSec.Key("STORAGE_TYPE").MustString("") + + Actions.LogStorage = getStorage(rootCfg, "actions_log", "", nil) + Actions.ArtifactStorage = getStorage(rootCfg, "actions_artifacts", storageType, actionsSec) } diff --git a/modules/storage/storage.go b/modules/storage/storage.go index caecab306e630..5b6efccb6a489 100644 --- a/modules/storage/storage.go +++ b/modules/storage/storage.go @@ -128,6 +128,8 @@ var ( // Actions represents actions storage Actions ObjectStorage = uninitializedStorage + // Actions Artifacts represents actions artifacts storage + ActionsArtifacts ObjectStorage = uninitializedStorage ) // Init init the stoarge @@ -212,9 +214,14 @@ func initPackages() (err error) { func initActions() (err error) { if !setting.Actions.Enabled { Actions = discardStorage("Actions isn't enabled") + ActionsArtifacts = discardStorage("ActionsArtifacts isn't enabled") return nil } - log.Info("Initialising Actions storage with type: %s", setting.Actions.Storage.Type) - Actions, err = NewStorage(setting.Actions.Storage.Type, &setting.Actions.Storage) + log.Info("Initialising Actions storage with type: %s", setting.Actions.LogStorage.Type) + if Actions, err = NewStorage(setting.Actions.LogStorage.Type, &setting.Actions.LogStorage); err != nil { + return err + } + log.Info("Initialising ActionsArtifacts storage with type: %s", setting.Actions.ArtifactStorage.Type) + ActionsArtifacts, err = NewStorage(setting.Actions.ArtifactStorage.Type, &setting.Actions.ArtifactStorage) return err } diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 78dbb3c9c2f40..6f85bc4d2d977 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -114,6 +114,8 @@ unknown = Unknown rss_feed = RSS Feed +artifacts = Artifacts + concept_system_global = Global concept_user_individual = Individual concept_code_repository = Repository diff --git a/routers/api/actions/artifacts.go b/routers/api/actions/artifacts.go new file mode 100644 index 0000000000000..61d432c8621e0 --- /dev/null +++ b/routers/api/actions/artifacts.go @@ -0,0 +1,587 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +// Github Actions Artifacts API Simple Description +// +// 1. Upload artifact +// 1.1. Post upload url +// Post: /api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts?api-version=6.0-preview +// Request: +// { +// "Type": "actions_storage", +// "Name": "artifact" +// } +// Response: +// { +// "fileContainerResourceUrl":"/api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/upload" +// } +// it acquires an upload url for artifact upload +// 1.2. Upload artifact +// PUT: /api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/upload?itemPath=artifact%2Ffilename +// it upload chunk with headers: +// x-tfs-filelength: 1024 // total file length +// content-length: 1024 // chunk length +// x-actions-results-md5: md5sum // md5sum of chunk +// content-range: bytes 0-1023/1024 // chunk range +// we save all chunks to one storage directory after md5sum check +// 1.3. Confirm upload +// PATCH: /api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/upload?itemPath=artifact%2Ffilename +// it confirm upload and merge all chunks to one file, save this file to storage +// +// 2. Download artifact +// 2.1 list artifacts +// GET: /api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts?api-version=6.0-preview +// Response: +// { +// "count": 1, +// "value": [ +// { +// "name": "artifact", +// "fileContainerResourceUrl": "/api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/path" +// } +// ] +// } +// 2.2 download artifact +// GET: /api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/path?api-version=6.0-preview +// Response: +// { +// "value": [ +// { +// "contentLocation": "/api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/download", +// "path": "artifact/filename", +// "itemType": "file" +// } +// ] +// } +// 2.3 download artifact file +// GET: /api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/download?itemPath=artifact%2Ffilename +// Response: +// download file +// + +import ( + "compress/gzip" + gocontext "context" + "crypto/md5" + "encoding/base64" + "errors" + "fmt" + "io" + "net/http" + "sort" + "strconv" + "strings" + "time" + + "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/storage" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" +) + +const ( + artifactXTfsFileLengthHeader = "x-tfs-filelength" + artifactXActionsResultsMD5Header = "x-actions-results-md5" +) + +const artifactRouteBase = "/_apis/pipelines/workflows/{run_id}/artifacts" + +func ArtifactsRoutes(goctx gocontext.Context, prefix string) *web.Route { + m := web.NewRoute() + m.Use(withContexter(goctx)) + + r := artifactRoutes{ + prefix: prefix, + fs: storage.ActionsArtifacts, + } + + m.Group(artifactRouteBase, func() { + // retrieve, list and confirm artifacts + m.Combo("").Get(r.listArtifacts).Post(r.getUploadArtifactURL).Patch(r.comfirmUploadArtifact) + // handle container artifacts list and download + m.Group("/{artifact_id}", func() { + m.Put("/upload", r.uploadArtifact) + m.Get("/path", r.getDownloadArtifactURL) + m.Get("/download", r.downloadArtifact) + }) + }) + + return m +} + +// withContexter initializes a package context for a request. +func withContexter(goctx gocontext.Context) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { + ctx := context.Context{ + Resp: context.NewResponse(resp), + Data: map[string]interface{}{}, + } + defer ctx.Close() + + // action task call server api with Bearer ACTIONS_RUNTIME_TOKEN + // we should verify the ACTIONS_RUNTIME_TOKEN + authHeader := req.Header.Get("Authorization") + if len(authHeader) == 0 || !strings.HasPrefix(authHeader, "Bearer ") { + ctx.Error(http.StatusUnauthorized, "Bad authorization header") + return + } + authToken := strings.TrimPrefix(authHeader, "Bearer ") + task, err := actions.GetRunningTaskByToken(req.Context(), authToken) + if err != nil { + log.Error("Error runner api getting task: %v", err) + ctx.Error(http.StatusInternalServerError, "Error runner api getting task") + return + } + ctx.Data["task"] = task + + if err := task.LoadJob(goctx); err != nil { + log.Error("Error runner api getting job: %v", err) + ctx.Error(http.StatusInternalServerError, "Error runner api getting job") + return + } + + ctx.Req = context.WithContext(req, &ctx) + + next.ServeHTTP(ctx.Resp, ctx.Req) + }) + } +} + +type artifactRoutes struct { + prefix string + fs storage.ObjectStorage +} + +func (ar artifactRoutes) buildArtifactURL(runID, artifactID int64, suffix string) string { + uploadURL := strings.TrimSuffix(setting.AppURL, "/") + strings.TrimSuffix(ar.prefix, "/") + + strings.ReplaceAll(artifactRouteBase, "{run_id}", strconv.FormatInt(runID, 10)) + + "/" + strconv.FormatInt(artifactID, 10) + "/" + suffix + return uploadURL +} + +type getUploadArtifactRequest struct { + Type string + Name string +} + +type getUploadArtifactResponse struct { + FileContainerResourceURL string `json:"fileContainerResourceUrl"` +} + +func (ar artifactRoutes) validateRunID(ctx *context.Context) (*actions.ActionTask, int64, bool) { + task, ok := ctx.Data["task"].(*actions.ActionTask) + if !ok { + log.Error("Error getting task in context") + ctx.Error(http.StatusInternalServerError, "Error getting task in context") + return nil, 0, false + } + runID := ctx.ParamsInt64("run_id") + if task.Job.RunID != runID { + log.Error("Error runID not match") + ctx.Error(http.StatusBadRequest, "run-id does not match") + return nil, 0, false + } + return task, runID, true +} + +// getUploadArtifactURL generates a URL for uploading an artifact +func (ar artifactRoutes) getUploadArtifactURL(ctx *context.Context) { + task, runID, ok := ar.validateRunID(ctx) + if !ok { + return + } + + var req getUploadArtifactRequest + if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil { + log.Error("Error decode request body: %v", err) + ctx.Error(http.StatusInternalServerError, "Error decode request body") + return + } + + artifact, err := actions.CreateArtifact(ctx, task, req.Name) + if err != nil { + log.Error("Error creating artifact: %v", err) + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + resp := getUploadArtifactResponse{ + FileContainerResourceURL: ar.buildArtifactURL(runID, artifact.ID, "upload"), + } + log.Debug("[artifact] get upload url: %s, artifact id: %d", resp.FileContainerResourceURL, artifact.ID) + ctx.JSON(http.StatusOK, resp) +} + +// getUploadFileSize returns the size of the file to be uploaded. +// The raw size is the size of the file as reported by the header X-TFS-FileLength. +func (ar artifactRoutes) getUploadFileSize(ctx *context.Context) (int64, int64, error) { + contentLength := ctx.Req.ContentLength + xTfsLength, _ := strconv.ParseInt(ctx.Req.Header.Get(artifactXTfsFileLengthHeader), 10, 64) + if xTfsLength > 0 { + return xTfsLength, contentLength, nil + } + return contentLength, contentLength, nil +} + +func (ar artifactRoutes) saveUploadChunk(ctx *context.Context, + artifact *actions.ActionArtifact, + contentSize, runID int64, +) (int64, error) { + contentRange := ctx.Req.Header.Get("Content-Range") + start, end, length := int64(0), int64(0), int64(0) + if _, err := fmt.Sscanf(contentRange, "bytes %d-%d/%d", &start, &end, &length); err != nil { + return -1, fmt.Errorf("parse content range error: %v", err) + } + + storagePath := fmt.Sprintf("tmp%d/%d-%d-%d.chunk", runID, artifact.ID, start, end) + + // use io.TeeReader to avoid reading all body to md5 sum. + // it writes data to hasher after reading end + // if hash is not matched, delete the read-end result + hasher := md5.New() + r := io.TeeReader(ctx.Req.Body, hasher) + + // save chunk to storage + writtenSize, err := ar.fs.Save(storagePath, r, -1) + if err != nil { + return -1, fmt.Errorf("save chunk to storage error: %v", err) + } + + // check md5 + reqMd5String := ctx.Req.Header.Get(artifactXActionsResultsMD5Header) + chunkMd5String := base64.StdEncoding.EncodeToString(hasher.Sum(nil)) + log.Debug("[artifact] check chunk md5, sum: %s, header: %s", chunkMd5String, reqMd5String) + if reqMd5String != chunkMd5String || writtenSize != contentSize { + if err := ar.fs.Delete(storagePath); err != nil { + log.Error("Error deleting chunk: %s, %v", storagePath, err) + } + return -1, fmt.Errorf("md5 not match") + } + + log.Debug("[artifact] save chunk %s, size: %d, artifact id: %d, start: %d, end: %d", + storagePath, contentSize, artifact.ID, start, end) + + return length, nil +} + +// The rules are from https://github.com/actions/toolkit/blob/main/packages/artifact/src/internal/path-and-artifact-name-validation.ts#L32 +var invalidArtifactNameChars = strings.Join([]string{"\\", "/", "\"", ":", "<", ">", "|", "*", "?", "\r", "\n"}, "") + +func (ar artifactRoutes) uploadArtifact(ctx *context.Context) { + _, runID, ok := ar.validateRunID(ctx) + if !ok { + return + } + artifactID := ctx.ParamsInt64("artifact_id") + + artifact, err := actions.GetArtifactByID(ctx, artifactID) + if errors.Is(err, util.ErrNotExist) { + log.Error("Error getting artifact: %v", err) + ctx.Error(http.StatusNotFound, err.Error()) + return + } else if err != nil { + log.Error("Error getting artifact: %v", err) + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + + // itemPath is generated from upload-artifact action + // it's formatted as {artifact_name}/{artfict_path_in_runner} + itemPath := util.PathJoinRel(ctx.Req.URL.Query().Get("itemPath")) + artifactName := strings.Split(itemPath, "/")[0] + + // checkArtifactName checks if the artifact name contains invalid characters. + // If the name contains invalid characters, an error is returned. + if strings.ContainsAny(artifactName, invalidArtifactNameChars) { + log.Error("Error checking artifact name contains invalid character") + ctx.Error(http.StatusBadRequest, err.Error()) + return + } + + // get upload file size + fileSize, contentLength, err := ar.getUploadFileSize(ctx) + if err != nil { + log.Error("Error getting upload file size: %v", err) + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + + // save chunk + chunkAllLength, err := ar.saveUploadChunk(ctx, artifact, contentLength, runID) + if err != nil { + log.Error("Error saving upload chunk: %v", err) + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + + // if artifact name is not set, update it + if artifact.ArtifactName == "" { + artifact.ArtifactName = artifactName + artifact.ArtifactPath = itemPath // path in container + artifact.FileSize = fileSize // this is total size of all chunks + artifact.FileCompressedSize = chunkAllLength + artifact.ContentEncoding = ctx.Req.Header.Get("Content-Encoding") + if err := actions.UpdateArtifactByID(ctx, artifact.ID, artifact); err != nil { + log.Error("Error updating artifact: %v", err) + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + } + + ctx.JSON(http.StatusOK, map[string]string{ + "message": "success", + }) +} + +// comfirmUploadArtifact comfirm upload artifact. +// if all chunks are uploaded, merge them to one file. +func (ar artifactRoutes) comfirmUploadArtifact(ctx *context.Context) { + _, runID, ok := ar.validateRunID(ctx) + if !ok { + return + } + if err := ar.mergeArtifactChunks(ctx, runID); err != nil { + log.Error("Error merging chunks: %v", err) + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + + ctx.JSON(http.StatusOK, map[string]string{ + "message": "success", + }) +} + +type chunkItem struct { + ArtifactID int64 + Start int64 + End int64 + Path string +} + +func (ar artifactRoutes) mergeArtifactChunks(ctx *context.Context, runID int64) error { + storageDir := fmt.Sprintf("tmp%d", runID) + var chunks []*chunkItem + if err := ar.fs.IterateObjects(storageDir, func(path string, obj storage.Object) error { + item := chunkItem{Path: path} + if _, err := fmt.Sscanf(path, storageDir+"/%d-%d-%d.chunk", &item.ArtifactID, &item.Start, &item.End); err != nil { + return fmt.Errorf("parse content range error: %v", err) + } + chunks = append(chunks, &item) + return nil + }); err != nil { + return err + } + // group chunks by artifact id + chunksMap := make(map[int64][]*chunkItem) + for _, c := range chunks { + chunksMap[c.ArtifactID] = append(chunksMap[c.ArtifactID], c) + } + + for artifactID, cs := range chunksMap { + // get artifact to handle merged chunks + artifact, err := actions.GetArtifactByID(ctx, cs[0].ArtifactID) + if err != nil { + return fmt.Errorf("get artifact error: %v", err) + } + + sort.Slice(cs, func(i, j int) bool { + return cs[i].Start < cs[j].Start + }) + + allChunks := make([]*chunkItem, 0) + startAt := int64(-1) + // check if all chunks are uploaded and in order and clean repeated chunks + for _, c := range cs { + // startAt is -1 means this is the first chunk + // previous c.ChunkEnd + 1 == c.ChunkStart means this chunk is in order + // StartAt is not -1 and c.ChunkStart is not startAt + 1 means there is a chunk missing + if c.Start == (startAt + 1) { + allChunks = append(allChunks, c) + startAt = c.End + } + } + + // if the last chunk.End + 1 is not equal to chunk.ChunkLength, means chunks are not uploaded completely + if startAt+1 != artifact.FileCompressedSize { + log.Debug("[artifact] chunks are not uploaded completely, artifact_id: %d", artifactID) + break + } + + // use multiReader + readers := make([]io.Reader, 0, len(allChunks)) + readerClosers := make([]io.Closer, 0, len(allChunks)) + for _, c := range allChunks { + reader, err := ar.fs.Open(c.Path) + if err != nil { + return fmt.Errorf("open chunk error: %v, %s", err, c.Path) + } + readers = append(readers, reader) + readerClosers = append(readerClosers, reader) + } + mergedReader := io.MultiReader(readers...) + + // if chunk is gzip, decompress it + if artifact.ContentEncoding == "gzip" { + var err error + mergedReader, err = gzip.NewReader(mergedReader) + if err != nil { + return fmt.Errorf("gzip reader error: %v", err) + } + } + + // save merged file + storagePath := fmt.Sprintf("%d/%d/%d.chunk", runID%255, artifactID%255, time.Now().UnixNano()) + written, err := ar.fs.Save(storagePath, mergedReader, -1) + if err != nil { + return fmt.Errorf("save merged file error: %v", err) + } + if written != artifact.FileSize { + return fmt.Errorf("merged file size is not equal to chunk length") + } + + // close readers + for _, r := range readerClosers { + r.Close() + } + + // save storage path to artifact + log.Debug("[artifact] merge chunks to artifact: %d, %s", artifact.ID, storagePath) + artifact.StoragePath = storagePath + artifact.Status = actions.ArtifactStatusUploadConfirmed + if err := actions.UpdateArtifactByID(ctx, artifact.ID, artifact); err != nil { + return fmt.Errorf("update artifact error: %v", err) + } + + // drop chunks + for _, c := range cs { + if err := ar.fs.Delete(c.Path); err != nil { + return fmt.Errorf("delete chunk file error: %v", err) + } + } + } + return nil +} + +type ( + listArtifactsResponse struct { + Count int64 `json:"count"` + Value []listArtifactsResponseItem `json:"value"` + } + listArtifactsResponseItem struct { + Name string `json:"name"` + FileContainerResourceURL string `json:"fileContainerResourceUrl"` + } +) + +func (ar artifactRoutes) listArtifacts(ctx *context.Context) { + _, runID, ok := ar.validateRunID(ctx) + if !ok { + return + } + + artficats, err := actions.ListArtifactsByRunID(ctx, runID) + if err != nil { + log.Error("Error getting artifacts: %v", err) + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + + artficatsData := make([]listArtifactsResponseItem, 0, len(artficats)) + for _, a := range artficats { + artficatsData = append(artficatsData, listArtifactsResponseItem{ + Name: a.ArtifactName, + FileContainerResourceURL: ar.buildArtifactURL(runID, a.ID, "path"), + }) + } + respData := listArtifactsResponse{ + Count: int64(len(artficatsData)), + Value: artficatsData, + } + ctx.JSON(http.StatusOK, respData) +} + +type ( + downloadArtifactResponse struct { + Value []downloadArtifactResponseItem `json:"value"` + } + downloadArtifactResponseItem struct { + Path string `json:"path"` + ItemType string `json:"itemType"` + ContentLocation string `json:"contentLocation"` + } +) + +func (ar artifactRoutes) getDownloadArtifactURL(ctx *context.Context) { + _, runID, ok := ar.validateRunID(ctx) + if !ok { + return + } + + artifactID := ctx.ParamsInt64("artifact_id") + artifact, err := actions.GetArtifactByID(ctx, artifactID) + if errors.Is(err, util.ErrNotExist) { + log.Error("Error getting artifact: %v", err) + ctx.Error(http.StatusNotFound, err.Error()) + return + } else if err != nil { + log.Error("Error getting artifact: %v", err) + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + downloadURL := ar.buildArtifactURL(runID, artifact.ID, "download") + itemPath := util.PathJoinRel(ctx.Req.URL.Query().Get("itemPath")) + respData := downloadArtifactResponse{ + Value: []downloadArtifactResponseItem{{ + Path: util.PathJoinRel(itemPath, artifact.ArtifactPath), + ItemType: "file", + ContentLocation: downloadURL, + }}, + } + ctx.JSON(http.StatusOK, respData) +} + +func (ar artifactRoutes) downloadArtifact(ctx *context.Context) { + _, runID, ok := ar.validateRunID(ctx) + if !ok { + return + } + + artifactID := ctx.ParamsInt64("artifact_id") + artifact, err := actions.GetArtifactByID(ctx, artifactID) + if errors.Is(err, util.ErrNotExist) { + log.Error("Error getting artifact: %v", err) + ctx.Error(http.StatusNotFound, err.Error()) + return + } else if err != nil { + log.Error("Error getting artifact: %v", err) + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + if artifact.RunID != runID { + log.Error("Error dismatch runID and artifactID, task: %v, artifact: %v", runID, artifactID) + ctx.Error(http.StatusBadRequest, err.Error()) + return + } + + fd, err := ar.fs.Open(artifact.StoragePath) + if err != nil { + log.Error("Error opening file: %v", err) + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + defer fd.Close() + + if strings.HasSuffix(artifact.ArtifactPath, ".gz") { + ctx.Resp.Header().Set("Content-Encoding", "gzip") + } + ctx.ServeContent(fd, &context.ServeHeaderOptions{ + Filename: artifact.ArtifactName, + LastModified: artifact.CreatedUnix.AsLocalTime(), + }) +} diff --git a/routers/init.go b/routers/init.go index 358922b1aea46..087d8c2915bb9 100644 --- a/routers/init.go +++ b/routers/init.go @@ -193,6 +193,12 @@ func NormalRoutes(ctx context.Context) *web.Route { if setting.Actions.Enabled { prefix := "/api/actions" r.Mount(prefix, actions_router.Routes(ctx, prefix)) + + // TODO: Pipeline api used for runner internal communication with gitea server. but only artifact is used for now. + // In Github, it uses ACTIONS_RUNTIME_URL=https://pipelines.actions.githubusercontent.com/fLgcSHkPGySXeIFrg8W8OBSfeg3b5Fls1A1CwX566g8PayEGlg/ + // TODO: this prefix should be generated with a token string with runner ? + prefix = "/api/actions_pipeline" + r.Mount(prefix, actions_router.ArtifactsRoutes(ctx, prefix)) } return r diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index c64b0fac394e5..211433861295e 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -16,6 +16,7 @@ import ( "code.gitea.io/gitea/modules/actions" "code.gitea.io/gitea/modules/base" context_module "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" @@ -418,3 +419,80 @@ func getRunJobs(ctx *context_module.Context, runIndex, jobIndex int64) (*actions } return jobs[0], jobs } + +type ArtifactsViewResponse struct { + Artifacts []*ArtifactsViewItem `json:"artifacts"` +} + +type ArtifactsViewItem struct { + Name string `json:"name"` + Size int64 `json:"size"` + ID int64 `json:"id"` +} + +func ArtifactsView(ctx *context_module.Context) { + runIndex := ctx.ParamsInt64("run") + run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, err.Error()) + return + } + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + artifacts, err := actions_model.ListUploadedArtifactsByRunID(ctx, run.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + artifactsResponse := ArtifactsViewResponse{ + Artifacts: make([]*ArtifactsViewItem, 0, len(artifacts)), + } + for _, art := range artifacts { + artifactsResponse.Artifacts = append(artifactsResponse.Artifacts, &ArtifactsViewItem{ + Name: art.ArtifactName, + Size: art.FileSize, + ID: art.ID, + }) + } + ctx.JSON(http.StatusOK, artifactsResponse) +} + +func ArtifactsDownloadView(ctx *context_module.Context) { + runIndex := ctx.ParamsInt64("run") + artifactID := ctx.ParamsInt64("id") + + artifact, err := actions_model.GetArtifactByID(ctx, artifactID) + if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, err.Error()) + } else if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, err.Error()) + return + } + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + if artifact.RunID != run.ID { + ctx.Error(http.StatusNotFound, "artifact not found") + return + } + + f, err := storage.ActionsArtifacts.Open(artifact.StoragePath) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + defer f.Close() + + ctx.ServeContent(f, &context_module.ServeHeaderOptions{ + Filename: artifact.ArtifactName, + LastModified: artifact.CreatedUnix.AsLocalTime(), + }) +} diff --git a/routers/web/web.go b/routers/web/web.go index 58623b4c67ff5..c230d33398c82 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1192,6 +1192,8 @@ func registerRoutes(m *web.Route) { }) m.Post("/cancel", reqRepoActionsWriter, actions.Cancel) m.Post("/approve", reqRepoActionsWriter, actions.Approve) + m.Post("/artifacts", actions.ArtifactsView) + m.Get("/artifacts/{id}", actions.ArtifactsDownloadView) m.Post("/rerun", reqRepoActionsWriter, actions.RerunAll) }) }, reqRepoActionsReader, actions.MustEnableActions) diff --git a/templates/repo/actions/view.tmpl b/templates/repo/actions/view.tmpl index 4a4418af174b4..8d6559ee98007 100644 --- a/templates/repo/actions/view.tmpl +++ b/templates/repo/actions/view.tmpl @@ -17,6 +17,7 @@ data-locale-status-cancelled="{{.locale.Tr "actions.status.cancelled"}}" data-locale-status-skipped="{{.locale.Tr "actions.status.skipped"}}" data-locale-status-blocked="{{.locale.Tr "actions.status.blocked"}}" + data-locale-artifacts-title="{{$.locale.Tr "artifacts"}}" > diff --git a/tests/integration/api_actions_artifact_test.go b/tests/integration/api_actions_artifact_test.go new file mode 100644 index 0000000000000..5f3884d9d6d45 --- /dev/null +++ b/tests/integration/api_actions_artifact_test.go @@ -0,0 +1,143 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "strings" + "testing" + + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestActionsArtifactUpload(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + type uploadArtifactResponse struct { + FileContainerResourceURL string `json:"fileContainerResourceUrl"` + } + + type getUploadArtifactRequest struct { + Type string + Name string + } + + // acquire artifact upload url + req := NewRequestWithJSON(t, "POST", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts", getUploadArtifactRequest{ + Type: "actions_storage", + Name: "artifact", + }) + req = addTokenAuthHeader(req, "Bearer 8061e833a55f6fc0157c98b883e91fcfeeb1a71a") + resp := MakeRequest(t, req, http.StatusOK) + var uploadResp uploadArtifactResponse + DecodeJSON(t, resp, &uploadResp) + assert.Contains(t, uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts") + + // get upload url + idx := strings.Index(uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/") + url := uploadResp.FileContainerResourceURL[idx:] + "?itemPath=artifact/abc.txt" + + // upload artifact chunk + body := strings.Repeat("A", 1024) + req = NewRequestWithBody(t, "PUT", url, strings.NewReader(body)) + req = addTokenAuthHeader(req, "Bearer 8061e833a55f6fc0157c98b883e91fcfeeb1a71a") + req.Header.Add("Content-Range", "bytes 0-1023/1024") + req.Header.Add("x-tfs-filelength", "1024") + req.Header.Add("x-actions-results-md5", "1HsSe8LeLWh93ILaw1TEFQ==") // base64(md5(body)) + MakeRequest(t, req, http.StatusOK) + + t.Logf("Create artifact confirm") + + // confirm artifact upload + req = NewRequest(t, "PATCH", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts") + req = addTokenAuthHeader(req, "Bearer 8061e833a55f6fc0157c98b883e91fcfeeb1a71a") + MakeRequest(t, req, http.StatusOK) +} + +func TestActionsArtifactUploadNotExist(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // artifact id 54321 not exist + url := "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts/54321/upload?itemPath=artifact/abc.txt" + body := strings.Repeat("A", 1024) + req := NewRequestWithBody(t, "PUT", url, strings.NewReader(body)) + req = addTokenAuthHeader(req, "Bearer 8061e833a55f6fc0157c98b883e91fcfeeb1a71a") + req.Header.Add("Content-Range", "bytes 0-1023/1024") + req.Header.Add("x-tfs-filelength", "1024") + req.Header.Add("x-actions-results-md5", "1HsSe8LeLWh93ILaw1TEFQ==") // base64(md5(body)) + MakeRequest(t, req, http.StatusNotFound) +} + +func TestActionsArtifactConfirmUpload(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + req := NewRequest(t, "PATCH", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts") + req = addTokenAuthHeader(req, "Bearer 8061e833a55f6fc0157c98b883e91fcfeeb1a71a") + resp := MakeRequest(t, req, http.StatusOK) + assert.Contains(t, resp.Body.String(), "success") +} + +func TestActionsArtifactUploadWithoutToken(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + req := NewRequestWithJSON(t, "POST", "/api/actions_pipeline/_apis/pipelines/workflows/1/artifacts", nil) + MakeRequest(t, req, http.StatusUnauthorized) +} + +func TestActionsArtifactDownload(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + type ( + listArtifactsResponseItem struct { + Name string `json:"name"` + FileContainerResourceURL string `json:"fileContainerResourceUrl"` + } + listArtifactsResponse struct { + Count int64 `json:"count"` + Value []listArtifactsResponseItem `json:"value"` + } + ) + + req := NewRequest(t, "GET", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts") + req = addTokenAuthHeader(req, "Bearer 8061e833a55f6fc0157c98b883e91fcfeeb1a71a") + resp := MakeRequest(t, req, http.StatusOK) + var listResp listArtifactsResponse + DecodeJSON(t, resp, &listResp) + assert.Equal(t, int64(1), listResp.Count) + assert.Equal(t, "artifact", listResp.Value[0].Name) + assert.Contains(t, listResp.Value[0].FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts") + + type ( + downloadArtifactResponseItem struct { + Path string `json:"path"` + ItemType string `json:"itemType"` + ContentLocation string `json:"contentLocation"` + } + downloadArtifactResponse struct { + Value []downloadArtifactResponseItem `json:"value"` + } + ) + + idx := strings.Index(listResp.Value[0].FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/") + url := listResp.Value[0].FileContainerResourceURL[idx+1:] + req = NewRequest(t, "GET", url) + req = addTokenAuthHeader(req, "Bearer 8061e833a55f6fc0157c98b883e91fcfeeb1a71a") + resp = MakeRequest(t, req, http.StatusOK) + var downloadResp downloadArtifactResponse + DecodeJSON(t, resp, &downloadResp) + assert.Len(t, downloadResp.Value, 1) + assert.Equal(t, "artifact/abc.txt", downloadResp.Value[0].Path) + assert.Equal(t, "file", downloadResp.Value[0].ItemType) + assert.Contains(t, downloadResp.Value[0].ContentLocation, "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts") + + idx = strings.Index(downloadResp.Value[0].ContentLocation, "/api/actions_pipeline/_apis/pipelines/") + url = downloadResp.Value[0].ContentLocation[idx:] + req = NewRequest(t, "GET", url) + req = addTokenAuthHeader(req, "Bearer 8061e833a55f6fc0157c98b883e91fcfeeb1a71a") + resp = MakeRequest(t, req, http.StatusOK) + body := strings.Repeat("A", 1024) + assert.Equal(t, resp.Body.String(), body) +} diff --git a/tests/mssql.ini.tmpl b/tests/mssql.ini.tmpl index 09e75acb019d7..10e70d35fc817 100644 --- a/tests/mssql.ini.tmpl +++ b/tests/mssql.ini.tmpl @@ -108,3 +108,6 @@ PATH = tests/{{TEST_TYPE}}/gitea-{{TEST_TYPE}}-mssql/data/lfs [packages] ENABLED = true + +[actions] +ENABLED = true diff --git a/tests/mysql.ini.tmpl b/tests/mysql.ini.tmpl index 6fcb2792c36b8..9d6bbd65e6e88 100644 --- a/tests/mysql.ini.tmpl +++ b/tests/mysql.ini.tmpl @@ -117,3 +117,6 @@ PASSWORD = debug USE_TLS = true SKIP_TLS_VERIFY = true REPLY_TO_ADDRESS = incoming+%{token}@localhost + +[actions] +ENABLED = true diff --git a/tests/mysql8.ini.tmpl b/tests/mysql8.ini.tmpl index e37052da8c598..6336441741844 100644 --- a/tests/mysql8.ini.tmpl +++ b/tests/mysql8.ini.tmpl @@ -105,3 +105,6 @@ PATH = tests/{{TEST_TYPE}}/gitea-{{TEST_TYPE}}-mysql8/data/lfs [packages] ENABLED = true + +[actions] +ENABLED = true diff --git a/tests/pgsql.ini.tmpl b/tests/pgsql.ini.tmpl index 0f538797c2bb9..dd45ab717aae6 100644 --- a/tests/pgsql.ini.tmpl +++ b/tests/pgsql.ini.tmpl @@ -129,3 +129,6 @@ MINIO_CHECKSUM_ALGORITHM = md5 [packages] ENABLED = true + +[actions] +ENABLED = true diff --git a/tests/sqlite.ini.tmpl b/tests/sqlite.ini.tmpl index 24aff9af431e4..969c15698daa9 100644 --- a/tests/sqlite.ini.tmpl +++ b/tests/sqlite.ini.tmpl @@ -114,3 +114,6 @@ FILE_EXTENSIONS = .html RENDER_COMMAND = `go run build/test-echo.go` IS_INPUT_FILE = false RENDER_CONTENT_MODE=sanitized + +[actions] +ENABLED = true diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue index 7685627da6963..28adfbc6eca71 100644 --- a/web_src/js/components/RepoActionView.vue +++ b/web_src/js/components/RepoActionView.vue @@ -42,6 +42,18 @@ +
+
+ {{ locale.artifactsTitle }} +
+ +
@@ -102,6 +114,7 @@ const sfc = { loading: false, intervalID: null, currentJobStepsStates: [], + artifacts: [], // provided by backend run: { @@ -156,6 +169,15 @@ const sfc = { this.intervalID = setInterval(this.loadJob, 1000); }, + unmounted() { + // clear the interval timer when the component is unmounted + // even our page is rendered once, not spa style + if (this.intervalID) { + clearInterval(this.intervalID); + this.intervalID = null; + } + }, + methods: { // get the active container element, either the `job-step-logs` or the `job-log-list` in the `job-log-group` getLogsContainer(idx) { @@ -259,6 +281,11 @@ const sfc = { try { this.loading = true; + // refresh artifacts if upload-artifact step done + const resp = await this.fetchPost(`${this.actionsURL}/runs/${this.runIndex}/artifacts`); + const artifacts = await resp.json(); + this.artifacts = artifacts['artifacts'] || []; + const response = await this.fetchJob(); // save the state to Vue data, then the UI will be updated @@ -287,6 +314,7 @@ const sfc = { } }, + fetchPost(url, body) { return fetch(url, { method: 'POST', @@ -319,6 +347,7 @@ export function initRepositoryActionView() { approve: el.getAttribute('data-locale-approve'), cancel: el.getAttribute('data-locale-cancel'), rerun: el.getAttribute('data-locale-rerun'), + artifactsTitle: el.getAttribute('data-locale-artifacts-title'), status: { unknown: el.getAttribute('data-locale-status-unknown'), waiting: el.getAttribute('data-locale-status-waiting'), @@ -423,6 +452,27 @@ export function ansiLogToHTML(line) { padding: 10px; } +.job-artifacts-title { + font-size: 18px; + margin-top: 16px; + padding: 16px 10px 0px 20px; + border-top: 1px solid var(--color-secondary); +} + +.job-artifacts-item { + margin: 5px 0; + padding: 6px; +} + +.job-artifacts-list { + padding-left: 12px; + list-style: none; +} + +.job-artifacts-icon { + padding-right: 3px; +} + .job-group-section .job-brief-list .job-brief-item { margin: 5px 0; padding: 10px; From 38cf43d0606c13c38f459659f38e26cf31dceccb Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Fri, 19 May 2023 22:17:48 +0800 Subject: [PATCH 04/27] Some refactors for issues stats (#24793) This PR - [x] Move some functions from `issues.go` to `issue_stats.go` and `issue_label.go` - [x] Remove duplicated issue options `UserIssueStatsOption` to keep only one `IssuesOptions` --- models/issues/issue.go | 192 -------- models/issues/issue_label.go | 490 +++++++++++++++++++++ models/issues/issue_search.go | 383 +--------------- models/issues/issue_stats.go | 383 ++++++++++++++++ models/issues/issue_test.go | 69 +-- models/issues/issue_update.go | 2 +- models/issues/label.go | 337 +------------- modules/indexer/issues/indexer.go | 2 +- routers/api/v1/repo/issue.go | 2 +- routers/web/repo/issue.go | 6 +- routers/web/user/home.go | 28 +- services/migrations/gitea_uploader_test.go | 2 +- 12 files changed, 948 insertions(+), 948 deletions(-) create mode 100644 models/issues/issue_label.go create mode 100644 models/issues/issue_stats.go diff --git a/models/issues/issue.go b/models/issues/issue.go index 8dc0381e02407..bf41a7ec28407 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -8,10 +8,8 @@ import ( "context" "fmt" "regexp" - "sort" "code.gitea.io/gitea/models/db" - access_model "code.gitea.io/gitea/models/perm/access" project_model "code.gitea.io/gitea/models/project" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" @@ -212,17 +210,6 @@ func (issue *Issue) GetPullRequest() (pr *PullRequest, err error) { return pr, err } -// LoadLabels loads labels -func (issue *Issue) LoadLabels(ctx context.Context) (err error) { - if issue.Labels == nil && issue.ID != 0 { - issue.Labels, err = GetLabelsByIssueID(ctx, issue.ID) - if err != nil { - return fmt.Errorf("getLabelsByIssueID [%d]: %w", issue.ID, err) - } - } - return nil -} - // LoadPoster loads poster func (issue *Issue) LoadPoster(ctx context.Context) (err error) { if issue.Poster == nil && issue.PosterID != 0 { @@ -459,175 +446,6 @@ func (issue *Issue) IsPoster(uid int64) bool { return issue.OriginalAuthorID == 0 && issue.PosterID == uid } -func (issue *Issue) getLabels(ctx context.Context) (err error) { - if len(issue.Labels) > 0 { - return nil - } - - issue.Labels, err = GetLabelsByIssueID(ctx, issue.ID) - if err != nil { - return fmt.Errorf("getLabelsByIssueID: %w", err) - } - return nil -} - -func clearIssueLabels(ctx context.Context, issue *Issue, doer *user_model.User) (err error) { - if err = issue.getLabels(ctx); err != nil { - return fmt.Errorf("getLabels: %w", err) - } - - for i := range issue.Labels { - if err = deleteIssueLabel(ctx, issue, issue.Labels[i], doer); err != nil { - return fmt.Errorf("removeLabel: %w", err) - } - } - - return nil -} - -// ClearIssueLabels removes all issue labels as the given user. -// Triggers appropriate WebHooks, if any. -func ClearIssueLabels(issue *Issue, doer *user_model.User) (err error) { - ctx, committer, err := db.TxContext(db.DefaultContext) - if err != nil { - return err - } - defer committer.Close() - - if err := issue.LoadRepo(ctx); err != nil { - return err - } else if err = issue.LoadPullRequest(ctx); err != nil { - return err - } - - perm, err := access_model.GetUserRepoPermission(ctx, issue.Repo, doer) - if err != nil { - return err - } - if !perm.CanWriteIssuesOrPulls(issue.IsPull) { - return ErrRepoLabelNotExist{} - } - - if err = clearIssueLabels(ctx, issue, doer); err != nil { - return err - } - - if err = committer.Commit(); err != nil { - return fmt.Errorf("Commit: %w", err) - } - - return nil -} - -type labelSorter []*Label - -func (ts labelSorter) Len() int { - return len([]*Label(ts)) -} - -func (ts labelSorter) Less(i, j int) bool { - return []*Label(ts)[i].ID < []*Label(ts)[j].ID -} - -func (ts labelSorter) Swap(i, j int) { - []*Label(ts)[i], []*Label(ts)[j] = []*Label(ts)[j], []*Label(ts)[i] -} - -// Ensure only one label of a given scope exists, with labels at the end of the -// array getting preference over earlier ones. -func RemoveDuplicateExclusiveLabels(labels []*Label) []*Label { - validLabels := make([]*Label, 0, len(labels)) - - for i, label := range labels { - scope := label.ExclusiveScope() - if scope != "" { - foundOther := false - for _, otherLabel := range labels[i+1:] { - if otherLabel.ExclusiveScope() == scope { - foundOther = true - break - } - } - if foundOther { - continue - } - } - validLabels = append(validLabels, label) - } - - return validLabels -} - -// ReplaceIssueLabels removes all current labels and add new labels to the issue. -// Triggers appropriate WebHooks, if any. -func ReplaceIssueLabels(issue *Issue, labels []*Label, doer *user_model.User) (err error) { - ctx, committer, err := db.TxContext(db.DefaultContext) - if err != nil { - return err - } - defer committer.Close() - - if err = issue.LoadRepo(ctx); err != nil { - return err - } - - if err = issue.LoadLabels(ctx); err != nil { - return err - } - - labels = RemoveDuplicateExclusiveLabels(labels) - - sort.Sort(labelSorter(labels)) - sort.Sort(labelSorter(issue.Labels)) - - var toAdd, toRemove []*Label - - addIndex, removeIndex := 0, 0 - for addIndex < len(labels) && removeIndex < len(issue.Labels) { - addLabel := labels[addIndex] - removeLabel := issue.Labels[removeIndex] - if addLabel.ID == removeLabel.ID { - // Silently drop invalid labels - if removeLabel.RepoID != issue.RepoID && removeLabel.OrgID != issue.Repo.OwnerID { - toRemove = append(toRemove, removeLabel) - } - - addIndex++ - removeIndex++ - } else if addLabel.ID < removeLabel.ID { - // Only add if the label is valid - if addLabel.RepoID == issue.RepoID || addLabel.OrgID == issue.Repo.OwnerID { - toAdd = append(toAdd, addLabel) - } - addIndex++ - } else { - toRemove = append(toRemove, removeLabel) - removeIndex++ - } - } - toAdd = append(toAdd, labels[addIndex:]...) - toRemove = append(toRemove, issue.Labels[removeIndex:]...) - - if len(toAdd) > 0 { - if err = newIssueLabels(ctx, issue, toAdd, doer); err != nil { - return fmt.Errorf("addLabels: %w", err) - } - } - - for _, l := range toRemove { - if err = deleteIssueLabel(ctx, issue, l, doer); err != nil { - return fmt.Errorf("removeLabel: %w", err) - } - } - - issue.Labels = nil - if err = issue.LoadLabels(ctx); err != nil { - return err - } - - return committer.Commit() -} - // GetTasks returns the amount of tasks in the issues content func (issue *Issue) GetTasks() int { return len(issueTasksPat.FindAllStringIndex(issue.Content, -1)) @@ -862,16 +680,6 @@ func (issue *Issue) GetExternalName() string { return issue.OriginalAuthor } // GetExternalID ExternalUserRemappable interface func (issue *Issue) GetExternalID() int64 { return issue.OriginalAuthorID } -// CountOrphanedIssues count issues without a repo -func CountOrphanedIssues(ctx context.Context) (int64, error) { - return db.GetEngine(ctx). - Table("issue"). - Join("LEFT", "repository", "issue.repo_id=repository.id"). - Where(builder.IsNull{"repository.id"}). - Select("COUNT(`issue`.`id`)"). - Count() -} - // HasOriginalAuthor returns if an issue was migrated and has an original author. func (issue *Issue) HasOriginalAuthor() bool { return issue.OriginalAuthor != "" && issue.OriginalAuthorID != 0 diff --git a/models/issues/issue_label.go b/models/issues/issue_label.go new file mode 100644 index 0000000000000..f4060b140260f --- /dev/null +++ b/models/issues/issue_label.go @@ -0,0 +1,490 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issues + +import ( + "context" + "fmt" + "sort" + + "code.gitea.io/gitea/models/db" + access_model "code.gitea.io/gitea/models/perm/access" + user_model "code.gitea.io/gitea/models/user" + + "xorm.io/builder" +) + +// IssueLabel represents an issue-label relation. +type IssueLabel struct { + ID int64 `xorm:"pk autoincr"` + IssueID int64 `xorm:"UNIQUE(s)"` + LabelID int64 `xorm:"UNIQUE(s)"` +} + +// HasIssueLabel returns true if issue has been labeled. +func HasIssueLabel(ctx context.Context, issueID, labelID int64) bool { + has, _ := db.GetEngine(ctx).Where("issue_id = ? AND label_id = ?", issueID, labelID).Get(new(IssueLabel)) + return has +} + +// newIssueLabel this function creates a new label it does not check if the label is valid for the issue +// YOU MUST CHECK THIS BEFORE THIS FUNCTION +func newIssueLabel(ctx context.Context, issue *Issue, label *Label, doer *user_model.User) (err error) { + if err = db.Insert(ctx, &IssueLabel{ + IssueID: issue.ID, + LabelID: label.ID, + }); err != nil { + return err + } + + if err = issue.LoadRepo(ctx); err != nil { + return + } + + opts := &CreateCommentOptions{ + Type: CommentTypeLabel, + Doer: doer, + Repo: issue.Repo, + Issue: issue, + Label: label, + Content: "1", + } + if _, err = CreateComment(ctx, opts); err != nil { + return err + } + + return updateLabelCols(ctx, label, "num_issues", "num_closed_issue") +} + +// Remove all issue labels in the given exclusive scope +func RemoveDuplicateExclusiveIssueLabels(ctx context.Context, issue *Issue, label *Label, doer *user_model.User) (err error) { + scope := label.ExclusiveScope() + if scope == "" { + return nil + } + + var toRemove []*Label + for _, issueLabel := range issue.Labels { + if label.ID != issueLabel.ID && issueLabel.ExclusiveScope() == scope { + toRemove = append(toRemove, issueLabel) + } + } + + for _, issueLabel := range toRemove { + if err = deleteIssueLabel(ctx, issue, issueLabel, doer); err != nil { + return err + } + } + + return nil +} + +// NewIssueLabel creates a new issue-label relation. +func NewIssueLabel(issue *Issue, label *Label, doer *user_model.User) (err error) { + if HasIssueLabel(db.DefaultContext, issue.ID, label.ID) { + return nil + } + + ctx, committer, err := db.TxContext(db.DefaultContext) + if err != nil { + return err + } + defer committer.Close() + + if err = issue.LoadRepo(ctx); err != nil { + return err + } + + // Do NOT add invalid labels + if issue.RepoID != label.RepoID && issue.Repo.OwnerID != label.OrgID { + return nil + } + + if err = RemoveDuplicateExclusiveIssueLabels(ctx, issue, label, doer); err != nil { + return nil + } + + if err = newIssueLabel(ctx, issue, label, doer); err != nil { + return err + } + + issue.Labels = nil + if err = issue.LoadLabels(ctx); err != nil { + return err + } + + return committer.Commit() +} + +// newIssueLabels add labels to an issue. It will check if the labels are valid for the issue +func newIssueLabels(ctx context.Context, issue *Issue, labels []*Label, doer *user_model.User) (err error) { + if err = issue.LoadRepo(ctx); err != nil { + return err + } + for _, l := range labels { + // Don't add already present labels and invalid labels + if HasIssueLabel(ctx, issue.ID, l.ID) || + (l.RepoID != issue.RepoID && l.OrgID != issue.Repo.OwnerID) { + continue + } + + if err = newIssueLabel(ctx, issue, l, doer); err != nil { + return fmt.Errorf("newIssueLabel: %w", err) + } + } + + return nil +} + +// NewIssueLabels creates a list of issue-label relations. +func NewIssueLabels(issue *Issue, labels []*Label, doer *user_model.User) (err error) { + ctx, committer, err := db.TxContext(db.DefaultContext) + if err != nil { + return err + } + defer committer.Close() + + if err = newIssueLabels(ctx, issue, labels, doer); err != nil { + return err + } + + issue.Labels = nil + if err = issue.LoadLabels(ctx); err != nil { + return err + } + + return committer.Commit() +} + +func deleteIssueLabel(ctx context.Context, issue *Issue, label *Label, doer *user_model.User) (err error) { + if count, err := db.DeleteByBean(ctx, &IssueLabel{ + IssueID: issue.ID, + LabelID: label.ID, + }); err != nil { + return err + } else if count == 0 { + return nil + } + + if err = issue.LoadRepo(ctx); err != nil { + return + } + + opts := &CreateCommentOptions{ + Type: CommentTypeLabel, + Doer: doer, + Repo: issue.Repo, + Issue: issue, + Label: label, + } + if _, err = CreateComment(ctx, opts); err != nil { + return err + } + + return updateLabelCols(ctx, label, "num_issues", "num_closed_issue") +} + +// DeleteIssueLabel deletes issue-label relation. +func DeleteIssueLabel(ctx context.Context, issue *Issue, label *Label, doer *user_model.User) error { + if err := deleteIssueLabel(ctx, issue, label, doer); err != nil { + return err + } + + issue.Labels = nil + return issue.LoadLabels(ctx) +} + +// DeleteLabelsByRepoID deletes labels of some repository +func DeleteLabelsByRepoID(ctx context.Context, repoID int64) error { + deleteCond := builder.Select("id").From("label").Where(builder.Eq{"label.repo_id": repoID}) + + if _, err := db.GetEngine(ctx).In("label_id", deleteCond). + Delete(&IssueLabel{}); err != nil { + return err + } + + _, err := db.DeleteByBean(ctx, &Label{RepoID: repoID}) + return err +} + +// CountOrphanedLabels return count of labels witch are broken and not accessible via ui anymore +func CountOrphanedLabels(ctx context.Context) (int64, error) { + noref, err := db.GetEngine(ctx).Table("label").Where("repo_id=? AND org_id=?", 0, 0).Count() + if err != nil { + return 0, err + } + + norepo, err := db.GetEngine(ctx).Table("label"). + Where(builder.And( + builder.Gt{"repo_id": 0}, + builder.NotIn("repo_id", builder.Select("id").From("`repository`")), + )). + Count() + if err != nil { + return 0, err + } + + noorg, err := db.GetEngine(ctx).Table("label"). + Where(builder.And( + builder.Gt{"org_id": 0}, + builder.NotIn("org_id", builder.Select("id").From("`user`")), + )). + Count() + if err != nil { + return 0, err + } + + return noref + norepo + noorg, nil +} + +// DeleteOrphanedLabels delete labels witch are broken and not accessible via ui anymore +func DeleteOrphanedLabels(ctx context.Context) error { + // delete labels with no reference + if _, err := db.GetEngine(ctx).Table("label").Where("repo_id=? AND org_id=?", 0, 0).Delete(new(Label)); err != nil { + return err + } + + // delete labels with none existing repos + if _, err := db.GetEngine(ctx). + Where(builder.And( + builder.Gt{"repo_id": 0}, + builder.NotIn("repo_id", builder.Select("id").From("`repository`")), + )). + Delete(Label{}); err != nil { + return err + } + + // delete labels with none existing orgs + if _, err := db.GetEngine(ctx). + Where(builder.And( + builder.Gt{"org_id": 0}, + builder.NotIn("org_id", builder.Select("id").From("`user`")), + )). + Delete(Label{}); err != nil { + return err + } + + return nil +} + +// CountOrphanedIssueLabels return count of IssueLabels witch have no label behind anymore +func CountOrphanedIssueLabels(ctx context.Context) (int64, error) { + return db.GetEngine(ctx).Table("issue_label"). + NotIn("label_id", builder.Select("id").From("label")). + Count() +} + +// DeleteOrphanedIssueLabels delete IssueLabels witch have no label behind anymore +func DeleteOrphanedIssueLabels(ctx context.Context) error { + _, err := db.GetEngine(ctx). + NotIn("label_id", builder.Select("id").From("label")). + Delete(IssueLabel{}) + return err +} + +// CountIssueLabelWithOutsideLabels count label comments with outside label +func CountIssueLabelWithOutsideLabels(ctx context.Context) (int64, error) { + return db.GetEngine(ctx).Where(builder.Expr("(label.org_id = 0 AND issue.repo_id != label.repo_id) OR (label.repo_id = 0 AND label.org_id != repository.owner_id)")). + Table("issue_label"). + Join("inner", "label", "issue_label.label_id = label.id "). + Join("inner", "issue", "issue.id = issue_label.issue_id "). + Join("inner", "repository", "issue.repo_id = repository.id"). + Count(new(IssueLabel)) +} + +// FixIssueLabelWithOutsideLabels fix label comments with outside label +func FixIssueLabelWithOutsideLabels(ctx context.Context) (int64, error) { + res, err := db.GetEngine(ctx).Exec(`DELETE FROM issue_label WHERE issue_label.id IN ( + SELECT il_too.id FROM ( + SELECT il_too_too.id + FROM issue_label AS il_too_too + INNER JOIN label ON il_too_too.label_id = label.id + INNER JOIN issue on issue.id = il_too_too.issue_id + INNER JOIN repository on repository.id = issue.repo_id + WHERE + (label.org_id = 0 AND issue.repo_id != label.repo_id) OR (label.repo_id = 0 AND label.org_id != repository.owner_id) + ) AS il_too )`) + if err != nil { + return 0, err + } + + return res.RowsAffected() +} + +// LoadLabels loads labels +func (issue *Issue) LoadLabels(ctx context.Context) (err error) { + if issue.Labels == nil && issue.ID != 0 { + issue.Labels, err = GetLabelsByIssueID(ctx, issue.ID) + if err != nil { + return fmt.Errorf("getLabelsByIssueID [%d]: %w", issue.ID, err) + } + } + return nil +} + +// GetLabelsByIssueID returns all labels that belong to given issue by ID. +func GetLabelsByIssueID(ctx context.Context, issueID int64) ([]*Label, error) { + var labels []*Label + return labels, db.GetEngine(ctx).Where("issue_label.issue_id = ?", issueID). + Join("LEFT", "issue_label", "issue_label.label_id = label.id"). + Asc("label.name"). + Find(&labels) +} + +func clearIssueLabels(ctx context.Context, issue *Issue, doer *user_model.User) (err error) { + if err = issue.LoadLabels(ctx); err != nil { + return fmt.Errorf("getLabels: %w", err) + } + + for i := range issue.Labels { + if err = deleteIssueLabel(ctx, issue, issue.Labels[i], doer); err != nil { + return fmt.Errorf("removeLabel: %w", err) + } + } + + return nil +} + +// ClearIssueLabels removes all issue labels as the given user. +// Triggers appropriate WebHooks, if any. +func ClearIssueLabels(issue *Issue, doer *user_model.User) (err error) { + ctx, committer, err := db.TxContext(db.DefaultContext) + if err != nil { + return err + } + defer committer.Close() + + if err := issue.LoadRepo(ctx); err != nil { + return err + } else if err = issue.LoadPullRequest(ctx); err != nil { + return err + } + + perm, err := access_model.GetUserRepoPermission(ctx, issue.Repo, doer) + if err != nil { + return err + } + if !perm.CanWriteIssuesOrPulls(issue.IsPull) { + return ErrRepoLabelNotExist{} + } + + if err = clearIssueLabels(ctx, issue, doer); err != nil { + return err + } + + if err = committer.Commit(); err != nil { + return fmt.Errorf("Commit: %w", err) + } + + return nil +} + +type labelSorter []*Label + +func (ts labelSorter) Len() int { + return len([]*Label(ts)) +} + +func (ts labelSorter) Less(i, j int) bool { + return []*Label(ts)[i].ID < []*Label(ts)[j].ID +} + +func (ts labelSorter) Swap(i, j int) { + []*Label(ts)[i], []*Label(ts)[j] = []*Label(ts)[j], []*Label(ts)[i] +} + +// Ensure only one label of a given scope exists, with labels at the end of the +// array getting preference over earlier ones. +func RemoveDuplicateExclusiveLabels(labels []*Label) []*Label { + validLabels := make([]*Label, 0, len(labels)) + + for i, label := range labels { + scope := label.ExclusiveScope() + if scope != "" { + foundOther := false + for _, otherLabel := range labels[i+1:] { + if otherLabel.ExclusiveScope() == scope { + foundOther = true + break + } + } + if foundOther { + continue + } + } + validLabels = append(validLabels, label) + } + + return validLabels +} + +// ReplaceIssueLabels removes all current labels and add new labels to the issue. +// Triggers appropriate WebHooks, if any. +func ReplaceIssueLabels(issue *Issue, labels []*Label, doer *user_model.User) (err error) { + ctx, committer, err := db.TxContext(db.DefaultContext) + if err != nil { + return err + } + defer committer.Close() + + if err = issue.LoadRepo(ctx); err != nil { + return err + } + + if err = issue.LoadLabels(ctx); err != nil { + return err + } + + labels = RemoveDuplicateExclusiveLabels(labels) + + sort.Sort(labelSorter(labels)) + sort.Sort(labelSorter(issue.Labels)) + + var toAdd, toRemove []*Label + + addIndex, removeIndex := 0, 0 + for addIndex < len(labels) && removeIndex < len(issue.Labels) { + addLabel := labels[addIndex] + removeLabel := issue.Labels[removeIndex] + if addLabel.ID == removeLabel.ID { + // Silently drop invalid labels + if removeLabel.RepoID != issue.RepoID && removeLabel.OrgID != issue.Repo.OwnerID { + toRemove = append(toRemove, removeLabel) + } + + addIndex++ + removeIndex++ + } else if addLabel.ID < removeLabel.ID { + // Only add if the label is valid + if addLabel.RepoID == issue.RepoID || addLabel.OrgID == issue.Repo.OwnerID { + toAdd = append(toAdd, addLabel) + } + addIndex++ + } else { + toRemove = append(toRemove, removeLabel) + removeIndex++ + } + } + toAdd = append(toAdd, labels[addIndex:]...) + toRemove = append(toRemove, issue.Labels[removeIndex:]...) + + if len(toAdd) > 0 { + if err = newIssueLabels(ctx, issue, toAdd, doer); err != nil { + return fmt.Errorf("addLabels: %w", err) + } + } + + for _, l := range toRemove { + if err = deleteIssueLabel(ctx, issue, l, doer); err != nil { + return fmt.Errorf("removeLabel: %w", err) + } + } + + issue.Labels = nil + if err = issue.LoadLabels(ctx); err != nil { + return err + } + + return committer.Commit() +} diff --git a/models/issues/issue_search.go b/models/issues/issue_search.go index e01070ecae003..9fd13f09956af 100644 --- a/models/issues/issue_search.go +++ b/models/issues/issue_search.go @@ -22,7 +22,7 @@ import ( // IssuesOptions represents options of an issue. type IssuesOptions struct { //nolint db.ListOptions - RepoID int64 // overwrites RepoCond if not 0 + RepoIDs []int64 // overwrites RepoCond if the length is not 0 RepoCond builder.Cond AssigneeID int64 PosterID int64 @@ -155,17 +155,24 @@ func applyMilestoneCondition(sess *xorm.Session, opts *IssuesOptions) *xorm.Sess return sess } +func applyRepoConditions(sess *xorm.Session, opts *IssuesOptions) *xorm.Session { + if len(opts.RepoIDs) == 1 { + opts.RepoCond = builder.Eq{"issue.repo_id": opts.RepoIDs[0]} + } else if len(opts.RepoIDs) > 1 { + opts.RepoCond = builder.In("issue.repo_id", opts.RepoIDs) + } + if opts.RepoCond != nil { + sess.And(opts.RepoCond) + } + return sess +} + func applyConditions(sess *xorm.Session, opts *IssuesOptions) *xorm.Session { if len(opts.IssueIDs) > 0 { sess.In("issue.id", opts.IssueIDs) } - if opts.RepoID != 0 { - opts.RepoCond = builder.Eq{"issue.repo_id": opts.RepoID} - } - if opts.RepoCond != nil { - sess.And(opts.RepoCond) - } + applyRepoConditions(sess, opts) if !opts.IsClosed.IsNone() { sess.And("issue.is_closed=?", opts.IsClosed.IsTrue()) @@ -400,31 +407,6 @@ func applySubscribedCondition(sess *xorm.Session, subscriberID int64) *xorm.Sess ) } -// CountIssuesByRepo map from repoID to number of issues matching the options -func CountIssuesByRepo(ctx context.Context, opts *IssuesOptions) (map[int64]int64, error) { - sess := db.GetEngine(ctx). - Join("INNER", "repository", "`issue`.repo_id = `repository`.id") - - applyConditions(sess, opts) - - countsSlice := make([]*struct { - RepoID int64 - Count int64 - }, 0, 10) - if err := sess.GroupBy("issue.repo_id"). - Select("issue.repo_id AS repo_id, COUNT(*) AS count"). - Table("issue"). - Find(&countsSlice); err != nil { - return nil, fmt.Errorf("unable to CountIssuesByRepo: %w", err) - } - - countMap := make(map[int64]int64, len(countsSlice)) - for _, c := range countsSlice { - countMap[c.RepoID] = c.Count - } - return countMap, nil -} - // GetRepoIDsForIssuesOptions find all repo ids for the given options func GetRepoIDsForIssuesOptions(opts *IssuesOptions, user *user_model.User) ([]int64, error) { repoIDs := make([]int64, 0, 5) @@ -453,351 +435,18 @@ func Issues(ctx context.Context, opts *IssuesOptions) ([]*Issue, error) { applyConditions(sess, opts) applySorts(sess, opts.SortType, opts.PriorityRepoID) - issues := make([]*Issue, 0, opts.ListOptions.PageSize) + issues := make(IssueList, 0, opts.ListOptions.PageSize) if err := sess.Find(&issues); err != nil { return nil, fmt.Errorf("unable to query Issues: %w", err) } - if err := IssueList(issues).LoadAttributes(); err != nil { + if err := issues.LoadAttributes(); err != nil { return nil, fmt.Errorf("unable to LoadAttributes for Issues: %w", err) } return issues, nil } -// CountIssues number return of issues by given conditions. -func CountIssues(ctx context.Context, opts *IssuesOptions) (int64, error) { - sess := db.GetEngine(ctx). - Select("COUNT(issue.id) AS count"). - Table("issue"). - Join("INNER", "repository", "`issue`.repo_id = `repository`.id") - applyConditions(sess, opts) - - return sess.Count() -} - -// IssueStats represents issue statistic information. -type IssueStats struct { - OpenCount, ClosedCount int64 - YourRepositoriesCount int64 - AssignCount int64 - CreateCount int64 - MentionCount int64 - ReviewRequestedCount int64 - ReviewedCount int64 -} - -// Filter modes. -const ( - FilterModeAll = iota - FilterModeAssign - FilterModeCreate - FilterModeMention - FilterModeReviewRequested - FilterModeReviewed - FilterModeYourRepositories -) - -const ( - // MaxQueryParameters represents the max query parameters - // When queries are broken down in parts because of the number - // of parameters, attempt to break by this amount - MaxQueryParameters = 300 -) - -// GetIssueStats returns issue statistic information by given conditions. -func GetIssueStats(opts *IssuesOptions) (*IssueStats, error) { - if len(opts.IssueIDs) <= MaxQueryParameters { - return getIssueStatsChunk(opts, opts.IssueIDs) - } - - // If too long a list of IDs is provided, we get the statistics in - // smaller chunks and get accumulates. Note: this could potentially - // get us invalid results. The alternative is to insert the list of - // ids in a temporary table and join from them. - accum := &IssueStats{} - for i := 0; i < len(opts.IssueIDs); { - chunk := i + MaxQueryParameters - if chunk > len(opts.IssueIDs) { - chunk = len(opts.IssueIDs) - } - stats, err := getIssueStatsChunk(opts, opts.IssueIDs[i:chunk]) - if err != nil { - return nil, err - } - accum.OpenCount += stats.OpenCount - accum.ClosedCount += stats.ClosedCount - accum.YourRepositoriesCount += stats.YourRepositoriesCount - accum.AssignCount += stats.AssignCount - accum.CreateCount += stats.CreateCount - accum.OpenCount += stats.MentionCount - accum.ReviewRequestedCount += stats.ReviewRequestedCount - accum.ReviewedCount += stats.ReviewedCount - i = chunk - } - return accum, nil -} - -func getIssueStatsChunk(opts *IssuesOptions, issueIDs []int64) (*IssueStats, error) { - stats := &IssueStats{} - - countSession := func(opts *IssuesOptions, issueIDs []int64) *xorm.Session { - sess := db.GetEngine(db.DefaultContext). - Where("issue.repo_id = ?", opts.RepoID) - - if len(issueIDs) > 0 { - sess.In("issue.id", issueIDs) - } - - applyLabelsCondition(sess, opts) - - applyMilestoneCondition(sess, opts) - - if opts.ProjectID > 0 { - sess.Join("INNER", "project_issue", "issue.id = project_issue.issue_id"). - And("project_issue.project_id=?", opts.ProjectID) - } - - if opts.AssigneeID > 0 { - applyAssigneeCondition(sess, opts.AssigneeID) - } else if opts.AssigneeID == db.NoConditionID { - sess.Where("id NOT IN (SELECT issue_id FROM issue_assignees)") - } - - if opts.PosterID > 0 { - applyPosterCondition(sess, opts.PosterID) - } - - if opts.MentionedID > 0 { - applyMentionedCondition(sess, opts.MentionedID) - } - - if opts.ReviewRequestedID > 0 { - applyReviewRequestedCondition(sess, opts.ReviewRequestedID) - } - - if opts.ReviewedID > 0 { - applyReviewedCondition(sess, opts.ReviewedID) - } - - switch opts.IsPull { - case util.OptionalBoolTrue: - sess.And("issue.is_pull=?", true) - case util.OptionalBoolFalse: - sess.And("issue.is_pull=?", false) - } - - return sess - } - - var err error - stats.OpenCount, err = countSession(opts, issueIDs). - And("issue.is_closed = ?", false). - Count(new(Issue)) - if err != nil { - return stats, err - } - stats.ClosedCount, err = countSession(opts, issueIDs). - And("issue.is_closed = ?", true). - Count(new(Issue)) - return stats, err -} - -// UserIssueStatsOptions contains parameters accepted by GetUserIssueStats. -type UserIssueStatsOptions struct { - UserID int64 - RepoIDs []int64 - FilterMode int - IsPull bool - IsClosed bool - IssueIDs []int64 - IsArchived util.OptionalBool - LabelIDs []int64 - RepoCond builder.Cond - Org *organization.Organization - Team *organization.Team -} - -// GetUserIssueStats returns issue statistic information for dashboard by given conditions. -func GetUserIssueStats(opts UserIssueStatsOptions) (*IssueStats, error) { - var err error - stats := &IssueStats{} - - cond := builder.NewCond() - cond = cond.And(builder.Eq{"issue.is_pull": opts.IsPull}) - if len(opts.RepoIDs) > 0 { - cond = cond.And(builder.In("issue.repo_id", opts.RepoIDs)) - } - if len(opts.IssueIDs) > 0 { - cond = cond.And(builder.In("issue.id", opts.IssueIDs)) - } - if opts.RepoCond != nil { - cond = cond.And(opts.RepoCond) - } - - if opts.UserID > 0 { - cond = cond.And(issuePullAccessibleRepoCond("issue.repo_id", opts.UserID, opts.Org, opts.Team, opts.IsPull)) - } - - sess := func(cond builder.Cond) *xorm.Session { - s := db.GetEngine(db.DefaultContext).Where(cond) - if len(opts.LabelIDs) > 0 { - s.Join("INNER", "issue_label", "issue_label.issue_id = issue.id"). - In("issue_label.label_id", opts.LabelIDs) - } - if opts.UserID > 0 || opts.IsArchived != util.OptionalBoolNone { - s.Join("INNER", "repository", "issue.repo_id = repository.id") - if opts.IsArchived != util.OptionalBoolNone { - s.And(builder.Eq{"repository.is_archived": opts.IsArchived.IsTrue()}) - } - } - return s - } - - switch opts.FilterMode { - case FilterModeAll, FilterModeYourRepositories: - stats.OpenCount, err = sess(cond). - And("issue.is_closed = ?", false). - Count(new(Issue)) - if err != nil { - return nil, err - } - stats.ClosedCount, err = sess(cond). - And("issue.is_closed = ?", true). - Count(new(Issue)) - if err != nil { - return nil, err - } - case FilterModeAssign: - stats.OpenCount, err = applyAssigneeCondition(sess(cond), opts.UserID). - And("issue.is_closed = ?", false). - Count(new(Issue)) - if err != nil { - return nil, err - } - stats.ClosedCount, err = applyAssigneeCondition(sess(cond), opts.UserID). - And("issue.is_closed = ?", true). - Count(new(Issue)) - if err != nil { - return nil, err - } - case FilterModeCreate: - stats.OpenCount, err = applyPosterCondition(sess(cond), opts.UserID). - And("issue.is_closed = ?", false). - Count(new(Issue)) - if err != nil { - return nil, err - } - stats.ClosedCount, err = applyPosterCondition(sess(cond), opts.UserID). - And("issue.is_closed = ?", true). - Count(new(Issue)) - if err != nil { - return nil, err - } - case FilterModeMention: - stats.OpenCount, err = applyMentionedCondition(sess(cond), opts.UserID). - And("issue.is_closed = ?", false). - Count(new(Issue)) - if err != nil { - return nil, err - } - stats.ClosedCount, err = applyMentionedCondition(sess(cond), opts.UserID). - And("issue.is_closed = ?", true). - Count(new(Issue)) - if err != nil { - return nil, err - } - case FilterModeReviewRequested: - stats.OpenCount, err = applyReviewRequestedCondition(sess(cond), opts.UserID). - And("issue.is_closed = ?", false). - Count(new(Issue)) - if err != nil { - return nil, err - } - stats.ClosedCount, err = applyReviewRequestedCondition(sess(cond), opts.UserID). - And("issue.is_closed = ?", true). - Count(new(Issue)) - if err != nil { - return nil, err - } - case FilterModeReviewed: - stats.OpenCount, err = applyReviewedCondition(sess(cond), opts.UserID). - And("issue.is_closed = ?", false). - Count(new(Issue)) - if err != nil { - return nil, err - } - stats.ClosedCount, err = applyReviewedCondition(sess(cond), opts.UserID). - And("issue.is_closed = ?", true). - Count(new(Issue)) - if err != nil { - return nil, err - } - } - - cond = cond.And(builder.Eq{"issue.is_closed": opts.IsClosed}) - stats.AssignCount, err = applyAssigneeCondition(sess(cond), opts.UserID).Count(new(Issue)) - if err != nil { - return nil, err - } - - stats.CreateCount, err = applyPosterCondition(sess(cond), opts.UserID).Count(new(Issue)) - if err != nil { - return nil, err - } - - stats.MentionCount, err = applyMentionedCondition(sess(cond), opts.UserID).Count(new(Issue)) - if err != nil { - return nil, err - } - - stats.YourRepositoriesCount, err = sess(cond).Count(new(Issue)) - if err != nil { - return nil, err - } - - stats.ReviewRequestedCount, err = applyReviewRequestedCondition(sess(cond), opts.UserID).Count(new(Issue)) - if err != nil { - return nil, err - } - - stats.ReviewedCount, err = applyReviewedCondition(sess(cond), opts.UserID).Count(new(Issue)) - if err != nil { - return nil, err - } - - return stats, nil -} - -// GetRepoIssueStats returns number of open and closed repository issues by given filter mode. -func GetRepoIssueStats(repoID, uid int64, filterMode int, isPull bool) (numOpen, numClosed int64) { - countSession := func(isClosed, isPull bool, repoID int64) *xorm.Session { - sess := db.GetEngine(db.DefaultContext). - Where("is_closed = ?", isClosed). - And("is_pull = ?", isPull). - And("repo_id = ?", repoID) - - return sess - } - - openCountSession := countSession(false, isPull, repoID) - closedCountSession := countSession(true, isPull, repoID) - - switch filterMode { - case FilterModeAssign: - applyAssigneeCondition(openCountSession, uid) - applyAssigneeCondition(closedCountSession, uid) - case FilterModeCreate: - applyPosterCondition(openCountSession, uid) - applyPosterCondition(closedCountSession, uid) - } - - openResult, _ := openCountSession.Count(new(Issue)) - closedResult, _ := closedCountSession.Count(new(Issue)) - - return openResult, closedResult -} - // SearchIssueIDsByKeyword search issues on database func SearchIssueIDsByKeyword(ctx context.Context, kw string, repoIDs []int64, limit, start int) (int64, []int64, error) { repoCond := builder.In("repo_id", repoIDs) diff --git a/models/issues/issue_stats.go b/models/issues/issue_stats.go new file mode 100644 index 0000000000000..9b9562ebdd795 --- /dev/null +++ b/models/issues/issue_stats.go @@ -0,0 +1,383 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issues + +import ( + "context" + "errors" + "fmt" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/util" + + "xorm.io/builder" + "xorm.io/xorm" +) + +// IssueStats represents issue statistic information. +type IssueStats struct { + OpenCount, ClosedCount int64 + YourRepositoriesCount int64 + AssignCount int64 + CreateCount int64 + MentionCount int64 + ReviewRequestedCount int64 + ReviewedCount int64 +} + +// Filter modes. +const ( + FilterModeAll = iota + FilterModeAssign + FilterModeCreate + FilterModeMention + FilterModeReviewRequested + FilterModeReviewed + FilterModeYourRepositories +) + +const ( + // MaxQueryParameters represents the max query parameters + // When queries are broken down in parts because of the number + // of parameters, attempt to break by this amount + MaxQueryParameters = 300 +) + +// CountIssuesByRepo map from repoID to number of issues matching the options +func CountIssuesByRepo(ctx context.Context, opts *IssuesOptions) (map[int64]int64, error) { + sess := db.GetEngine(ctx). + Join("INNER", "repository", "`issue`.repo_id = `repository`.id") + + applyConditions(sess, opts) + + countsSlice := make([]*struct { + RepoID int64 + Count int64 + }, 0, 10) + if err := sess.GroupBy("issue.repo_id"). + Select("issue.repo_id AS repo_id, COUNT(*) AS count"). + Table("issue"). + Find(&countsSlice); err != nil { + return nil, fmt.Errorf("unable to CountIssuesByRepo: %w", err) + } + + countMap := make(map[int64]int64, len(countsSlice)) + for _, c := range countsSlice { + countMap[c.RepoID] = c.Count + } + return countMap, nil +} + +// CountIssues number return of issues by given conditions. +func CountIssues(ctx context.Context, opts *IssuesOptions) (int64, error) { + sess := db.GetEngine(ctx). + Select("COUNT(issue.id) AS count"). + Table("issue"). + Join("INNER", "repository", "`issue`.repo_id = `repository`.id") + applyConditions(sess, opts) + + return sess.Count() +} + +// GetIssueStats returns issue statistic information by given conditions. +func GetIssueStats(opts *IssuesOptions) (*IssueStats, error) { + if len(opts.IssueIDs) <= MaxQueryParameters { + return getIssueStatsChunk(opts, opts.IssueIDs) + } + + // If too long a list of IDs is provided, we get the statistics in + // smaller chunks and get accumulates. Note: this could potentially + // get us invalid results. The alternative is to insert the list of + // ids in a temporary table and join from them. + accum := &IssueStats{} + for i := 0; i < len(opts.IssueIDs); { + chunk := i + MaxQueryParameters + if chunk > len(opts.IssueIDs) { + chunk = len(opts.IssueIDs) + } + stats, err := getIssueStatsChunk(opts, opts.IssueIDs[i:chunk]) + if err != nil { + return nil, err + } + accum.OpenCount += stats.OpenCount + accum.ClosedCount += stats.ClosedCount + accum.YourRepositoriesCount += stats.YourRepositoriesCount + accum.AssignCount += stats.AssignCount + accum.CreateCount += stats.CreateCount + accum.OpenCount += stats.MentionCount + accum.ReviewRequestedCount += stats.ReviewRequestedCount + accum.ReviewedCount += stats.ReviewedCount + i = chunk + } + return accum, nil +} + +func getIssueStatsChunk(opts *IssuesOptions, issueIDs []int64) (*IssueStats, error) { + stats := &IssueStats{} + + countSession := func(opts *IssuesOptions, issueIDs []int64) *xorm.Session { + sess := db.GetEngine(db.DefaultContext). + Join("INNER", "repository", "`issue`.repo_id = `repository`.id") + if len(opts.RepoIDs) > 1 { + sess.In("issue.repo_id", opts.RepoIDs) + } else if len(opts.RepoIDs) == 1 { + sess.And("issue.repo_id = ?", opts.RepoIDs[0]) + } + + if len(issueIDs) > 0 { + sess.In("issue.id", issueIDs) + } + + applyLabelsCondition(sess, opts) + + applyMilestoneCondition(sess, opts) + + if opts.ProjectID > 0 { + sess.Join("INNER", "project_issue", "issue.id = project_issue.issue_id"). + And("project_issue.project_id=?", opts.ProjectID) + } + + if opts.AssigneeID > 0 { + applyAssigneeCondition(sess, opts.AssigneeID) + } else if opts.AssigneeID == db.NoConditionID { + sess.Where("id NOT IN (SELECT issue_id FROM issue_assignees)") + } + + if opts.PosterID > 0 { + applyPosterCondition(sess, opts.PosterID) + } + + if opts.MentionedID > 0 { + applyMentionedCondition(sess, opts.MentionedID) + } + + if opts.ReviewRequestedID > 0 { + applyReviewRequestedCondition(sess, opts.ReviewRequestedID) + } + + if opts.ReviewedID > 0 { + applyReviewedCondition(sess, opts.ReviewedID) + } + + switch opts.IsPull { + case util.OptionalBoolTrue: + sess.And("issue.is_pull=?", true) + case util.OptionalBoolFalse: + sess.And("issue.is_pull=?", false) + } + + return sess + } + + var err error + stats.OpenCount, err = countSession(opts, issueIDs). + And("issue.is_closed = ?", false). + Count(new(Issue)) + if err != nil { + return stats, err + } + stats.ClosedCount, err = countSession(opts, issueIDs). + And("issue.is_closed = ?", true). + Count(new(Issue)) + return stats, err +} + +// GetUserIssueStats returns issue statistic information for dashboard by given conditions. +func GetUserIssueStats(filterMode int, opts IssuesOptions) (*IssueStats, error) { + if opts.User == nil { + return nil, errors.New("issue stats without user") + } + if opts.IsPull.IsNone() { + return nil, errors.New("unaccepted ispull option") + } + + var err error + stats := &IssueStats{} + + cond := builder.NewCond() + + cond = cond.And(builder.Eq{"issue.is_pull": opts.IsPull.IsTrue()}) + + if len(opts.RepoIDs) > 0 { + cond = cond.And(builder.In("issue.repo_id", opts.RepoIDs)) + } + if len(opts.IssueIDs) > 0 { + cond = cond.And(builder.In("issue.id", opts.IssueIDs)) + } + if opts.RepoCond != nil { + cond = cond.And(opts.RepoCond) + } + + if opts.User != nil { + cond = cond.And(issuePullAccessibleRepoCond("issue.repo_id", opts.User.ID, opts.Org, opts.Team, opts.IsPull.IsTrue())) + } + + sess := func(cond builder.Cond) *xorm.Session { + s := db.GetEngine(db.DefaultContext). + Join("INNER", "repository", "`issue`.repo_id = `repository`.id"). + Where(cond) + if len(opts.LabelIDs) > 0 { + s.Join("INNER", "issue_label", "issue_label.issue_id = issue.id"). + In("issue_label.label_id", opts.LabelIDs) + } + + if opts.IsArchived != util.OptionalBoolNone { + s.And(builder.Eq{"repository.is_archived": opts.IsArchived.IsTrue()}) + } + return s + } + + switch filterMode { + case FilterModeAll, FilterModeYourRepositories: + stats.OpenCount, err = sess(cond). + And("issue.is_closed = ?", false). + Count(new(Issue)) + if err != nil { + return nil, err + } + stats.ClosedCount, err = sess(cond). + And("issue.is_closed = ?", true). + Count(new(Issue)) + if err != nil { + return nil, err + } + case FilterModeAssign: + stats.OpenCount, err = applyAssigneeCondition(sess(cond), opts.User.ID). + And("issue.is_closed = ?", false). + Count(new(Issue)) + if err != nil { + return nil, err + } + stats.ClosedCount, err = applyAssigneeCondition(sess(cond), opts.User.ID). + And("issue.is_closed = ?", true). + Count(new(Issue)) + if err != nil { + return nil, err + } + case FilterModeCreate: + stats.OpenCount, err = applyPosterCondition(sess(cond), opts.User.ID). + And("issue.is_closed = ?", false). + Count(new(Issue)) + if err != nil { + return nil, err + } + stats.ClosedCount, err = applyPosterCondition(sess(cond), opts.User.ID). + And("issue.is_closed = ?", true). + Count(new(Issue)) + if err != nil { + return nil, err + } + case FilterModeMention: + stats.OpenCount, err = applyMentionedCondition(sess(cond), opts.User.ID). + And("issue.is_closed = ?", false). + Count(new(Issue)) + if err != nil { + return nil, err + } + stats.ClosedCount, err = applyMentionedCondition(sess(cond), opts.User.ID). + And("issue.is_closed = ?", true). + Count(new(Issue)) + if err != nil { + return nil, err + } + case FilterModeReviewRequested: + stats.OpenCount, err = applyReviewRequestedCondition(sess(cond), opts.User.ID). + And("issue.is_closed = ?", false). + Count(new(Issue)) + if err != nil { + return nil, err + } + stats.ClosedCount, err = applyReviewRequestedCondition(sess(cond), opts.User.ID). + And("issue.is_closed = ?", true). + Count(new(Issue)) + if err != nil { + return nil, err + } + case FilterModeReviewed: + stats.OpenCount, err = applyReviewedCondition(sess(cond), opts.User.ID). + And("issue.is_closed = ?", false). + Count(new(Issue)) + if err != nil { + return nil, err + } + stats.ClosedCount, err = applyReviewedCondition(sess(cond), opts.User.ID). + And("issue.is_closed = ?", true). + Count(new(Issue)) + if err != nil { + return nil, err + } + } + + cond = cond.And(builder.Eq{"issue.is_closed": opts.IsClosed.IsTrue()}) + stats.AssignCount, err = applyAssigneeCondition(sess(cond), opts.User.ID).Count(new(Issue)) + if err != nil { + return nil, err + } + + stats.CreateCount, err = applyPosterCondition(sess(cond), opts.User.ID).Count(new(Issue)) + if err != nil { + return nil, err + } + + stats.MentionCount, err = applyMentionedCondition(sess(cond), opts.User.ID).Count(new(Issue)) + if err != nil { + return nil, err + } + + stats.YourRepositoriesCount, err = sess(cond).Count(new(Issue)) + if err != nil { + return nil, err + } + + stats.ReviewRequestedCount, err = applyReviewRequestedCondition(sess(cond), opts.User.ID).Count(new(Issue)) + if err != nil { + return nil, err + } + + stats.ReviewedCount, err = applyReviewedCondition(sess(cond), opts.User.ID).Count(new(Issue)) + if err != nil { + return nil, err + } + + return stats, nil +} + +// GetRepoIssueStats returns number of open and closed repository issues by given filter mode. +func GetRepoIssueStats(repoID, uid int64, filterMode int, isPull bool) (numOpen, numClosed int64) { + countSession := func(isClosed, isPull bool, repoID int64) *xorm.Session { + sess := db.GetEngine(db.DefaultContext). + Where("is_closed = ?", isClosed). + And("is_pull = ?", isPull). + And("repo_id = ?", repoID) + + return sess + } + + openCountSession := countSession(false, isPull, repoID) + closedCountSession := countSession(true, isPull, repoID) + + switch filterMode { + case FilterModeAssign: + applyAssigneeCondition(openCountSession, uid) + applyAssigneeCondition(closedCountSession, uid) + case FilterModeCreate: + applyPosterCondition(openCountSession, uid) + applyPosterCondition(closedCountSession, uid) + } + + openResult, _ := openCountSession.Count(new(Issue)) + closedResult, _ := closedCountSession.Count(new(Issue)) + + return openResult, closedResult +} + +// CountOrphanedIssues count issues without a repo +func CountOrphanedIssues(ctx context.Context) (int64, error) { + return db.GetEngine(ctx). + Table("issue"). + Join("LEFT", "repository", "issue.repo_id=repository.id"). + Where(builder.IsNull{"repository.id"}). + Select("COUNT(`issue`.`id`)"). + Count() +} diff --git a/models/issues/issue_test.go b/models/issues/issue_test.go index 5bf2f819be86b..80699a57b4e86 100644 --- a/models/issues/issue_test.go +++ b/models/issues/issue_test.go @@ -17,6 +17,7 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/util" "github.com/stretchr/testify/assert" "xorm.io/builder" @@ -204,14 +205,16 @@ func TestIssues(t *testing.T) { func TestGetUserIssueStats(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) for _, test := range []struct { - Opts issues_model.UserIssueStatsOptions + FilterMode int + Opts issues_model.IssuesOptions ExpectedIssueStats issues_model.IssueStats }{ { - issues_model.UserIssueStatsOptions{ - UserID: 1, - RepoIDs: []int64{1}, - FilterMode: issues_model.FilterModeAll, + issues_model.FilterModeAll, + issues_model.IssuesOptions{ + User: unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}), + RepoIDs: []int64{1}, + IsPull: util.OptionalBoolFalse, }, issues_model.IssueStats{ YourRepositoriesCount: 1, // 6 @@ -222,11 +225,12 @@ func TestGetUserIssueStats(t *testing.T) { }, }, { - issues_model.UserIssueStatsOptions{ - UserID: 1, - RepoIDs: []int64{1}, - FilterMode: issues_model.FilterModeAll, - IsClosed: true, + issues_model.FilterModeAll, + issues_model.IssuesOptions{ + User: unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}), + RepoIDs: []int64{1}, + IsPull: util.OptionalBoolFalse, + IsClosed: util.OptionalBoolTrue, }, issues_model.IssueStats{ YourRepositoriesCount: 1, // 6 @@ -237,9 +241,10 @@ func TestGetUserIssueStats(t *testing.T) { }, }, { - issues_model.UserIssueStatsOptions{ - UserID: 1, - FilterMode: issues_model.FilterModeAssign, + issues_model.FilterModeAssign, + issues_model.IssuesOptions{ + User: unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}), + IsPull: util.OptionalBoolFalse, }, issues_model.IssueStats{ YourRepositoriesCount: 1, // 6 @@ -250,9 +255,10 @@ func TestGetUserIssueStats(t *testing.T) { }, }, { - issues_model.UserIssueStatsOptions{ - UserID: 1, - FilterMode: issues_model.FilterModeCreate, + issues_model.FilterModeCreate, + issues_model.IssuesOptions{ + User: unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}), + IsPull: util.OptionalBoolFalse, }, issues_model.IssueStats{ YourRepositoriesCount: 1, // 6 @@ -263,9 +269,10 @@ func TestGetUserIssueStats(t *testing.T) { }, }, { - issues_model.UserIssueStatsOptions{ - UserID: 1, - FilterMode: issues_model.FilterModeMention, + issues_model.FilterModeMention, + issues_model.IssuesOptions{ + User: unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}), + IsPull: util.OptionalBoolFalse, }, issues_model.IssueStats{ YourRepositoriesCount: 1, // 6 @@ -277,10 +284,11 @@ func TestGetUserIssueStats(t *testing.T) { }, }, { - issues_model.UserIssueStatsOptions{ - UserID: 1, - FilterMode: issues_model.FilterModeCreate, - IssueIDs: []int64{1}, + issues_model.FilterModeCreate, + issues_model.IssuesOptions{ + User: unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}), + IssueIDs: []int64{1}, + IsPull: util.OptionalBoolFalse, }, issues_model.IssueStats{ YourRepositoriesCount: 1, // 1 @@ -291,11 +299,12 @@ func TestGetUserIssueStats(t *testing.T) { }, }, { - issues_model.UserIssueStatsOptions{ - UserID: 2, - Org: unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3}), - Team: unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 7}), - FilterMode: issues_model.FilterModeAll, + issues_model.FilterModeAll, + issues_model.IssuesOptions{ + User: unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}), + Org: unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3}), + Team: unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 7}), + IsPull: util.OptionalBoolFalse, }, issues_model.IssueStats{ YourRepositoriesCount: 2, @@ -306,7 +315,7 @@ func TestGetUserIssueStats(t *testing.T) { }, } { t.Run(fmt.Sprintf("%#v", test.Opts), func(t *testing.T) { - stats, err := issues_model.GetUserIssueStats(test.Opts) + stats, err := issues_model.GetUserIssueStats(test.FilterMode, test.Opts) if !assert.NoError(t, err) { return } @@ -495,7 +504,7 @@ func TestCorrectIssueStats(t *testing.T) { // Now we will call the GetIssueStats with these IDs and if working, // get the correct stats back. issueStats, err := issues_model.GetIssueStats(&issues_model.IssuesOptions{ - RepoID: 1, + RepoIDs: []int64{1}, IssueIDs: ids, }) diff --git a/models/issues/issue_update.go b/models/issues/issue_update.go index bebd5f4cd6592..b6fd720fe5eb8 100644 --- a/models/issues/issue_update.go +++ b/models/issues/issue_update.go @@ -81,7 +81,7 @@ func doChangeIssueStatus(ctx context.Context, issue *Issue, doer *user_model.Use } // Update issue count of labels - if err := issue.getLabels(ctx); err != nil { + if err := issue.LoadLabels(ctx); err != nil { return nil, err } for idx := range issue.Labels { diff --git a/models/issues/label.go b/models/issues/label.go index 9c22dcdd2d265..8f2cf05a28d65 100644 --- a/models/issues/label.go +++ b/models/issues/label.go @@ -11,7 +11,6 @@ import ( "strings" "code.gitea.io/gitea/models/db" - user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/label" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" @@ -113,7 +112,7 @@ func (l *Label) CalOpenIssues() { // CalOpenOrgIssues calculates the open issues of a label for a specific repo func (l *Label) CalOpenOrgIssues(ctx context.Context, repoID, labelID int64) { counts, _ := CountIssuesByRepo(ctx, &IssuesOptions{ - RepoID: repoID, + RepoIDs: []int64{repoID}, LabelIDs: []int64{labelID}, IsClosed: util.OptionalBoolFalse, }) @@ -282,13 +281,6 @@ func GetLabelsByIDs(labelIDs []int64) ([]*Label, error) { Find(&labels) } -// __________ .__ __ -// \______ \ ____ ______ ____ _____|__|/ |_ ___________ ___.__. -// | _// __ \\____ \ / _ \/ ___/ \ __\/ _ \_ __ < | | -// | | \ ___/| |_> > <_> )___ \| || | ( <_> ) | \/\___ | -// |____|_ /\___ > __/ \____/____ >__||__| \____/|__| / ____| -// \/ \/|__| \/ \/ - // GetLabelInRepoByName returns a label by name in given repository. func GetLabelInRepoByName(ctx context.Context, repoID int64, labelName string) (*Label, error) { if len(labelName) == 0 || repoID <= 0 { @@ -393,13 +385,6 @@ func CountLabelsByRepoID(repoID int64) (int64, error) { return db.GetEngine(db.DefaultContext).Where("repo_id = ?", repoID).Count(&Label{}) } -// ________ -// \_____ \_______ ____ -// / | \_ __ \/ ___\ -// / | \ | \/ /_/ > -// \_______ /__| \___ / -// \/ /_____/ - // GetLabelInOrgByName returns a label by name in given organization. func GetLabelInOrgByName(ctx context.Context, orgID int64, labelName string) (*Label, error) { if len(labelName) == 0 || orgID <= 0 { @@ -496,22 +481,6 @@ func CountLabelsByOrgID(orgID int64) (int64, error) { return db.GetEngine(db.DefaultContext).Where("org_id = ?", orgID).Count(&Label{}) } -// .___ -// | | ______ ________ __ ____ -// | |/ ___// ___/ | \_/ __ \ -// | |\___ \ \___ \| | /\ ___/ -// |___/____ >____ >____/ \___ | -// \/ \/ \/ - -// GetLabelsByIssueID returns all labels that belong to given issue by ID. -func GetLabelsByIssueID(ctx context.Context, issueID int64) ([]*Label, error) { - var labels []*Label - return labels, db.GetEngine(ctx).Where("issue_label.issue_id = ?", issueID). - Join("LEFT", "issue_label", "issue_label.label_id = label.id"). - Asc("label.name"). - Find(&labels) -} - func updateLabelCols(ctx context.Context, l *Label, cols ...string) error { _, err := db.GetEngine(ctx).ID(l.ID). SetExpr("num_issues", @@ -529,307 +498,3 @@ func updateLabelCols(ctx context.Context, l *Label, cols ...string) error { Cols(cols...).Update(l) return err } - -// .___ .____ ___. .__ -// | | ______ ________ __ ____ | | _____ \_ |__ ____ | | -// | |/ ___// ___/ | \_/ __ \| | \__ \ | __ \_/ __ \| | -// | |\___ \ \___ \| | /\ ___/| |___ / __ \| \_\ \ ___/| |__ -// |___/____ >____ >____/ \___ >_______ (____ /___ /\___ >____/ -// \/ \/ \/ \/ \/ \/ \/ - -// IssueLabel represents an issue-label relation. -type IssueLabel struct { - ID int64 `xorm:"pk autoincr"` - IssueID int64 `xorm:"UNIQUE(s)"` - LabelID int64 `xorm:"UNIQUE(s)"` -} - -// HasIssueLabel returns true if issue has been labeled. -func HasIssueLabel(ctx context.Context, issueID, labelID int64) bool { - has, _ := db.GetEngine(ctx).Where("issue_id = ? AND label_id = ?", issueID, labelID).Get(new(IssueLabel)) - return has -} - -// newIssueLabel this function creates a new label it does not check if the label is valid for the issue -// YOU MUST CHECK THIS BEFORE THIS FUNCTION -func newIssueLabel(ctx context.Context, issue *Issue, label *Label, doer *user_model.User) (err error) { - if err = db.Insert(ctx, &IssueLabel{ - IssueID: issue.ID, - LabelID: label.ID, - }); err != nil { - return err - } - - if err = issue.LoadRepo(ctx); err != nil { - return - } - - opts := &CreateCommentOptions{ - Type: CommentTypeLabel, - Doer: doer, - Repo: issue.Repo, - Issue: issue, - Label: label, - Content: "1", - } - if _, err = CreateComment(ctx, opts); err != nil { - return err - } - - return updateLabelCols(ctx, label, "num_issues", "num_closed_issue") -} - -// Remove all issue labels in the given exclusive scope -func RemoveDuplicateExclusiveIssueLabels(ctx context.Context, issue *Issue, label *Label, doer *user_model.User) (err error) { - scope := label.ExclusiveScope() - if scope == "" { - return nil - } - - var toRemove []*Label - for _, issueLabel := range issue.Labels { - if label.ID != issueLabel.ID && issueLabel.ExclusiveScope() == scope { - toRemove = append(toRemove, issueLabel) - } - } - - for _, issueLabel := range toRemove { - if err = deleteIssueLabel(ctx, issue, issueLabel, doer); err != nil { - return err - } - } - - return nil -} - -// NewIssueLabel creates a new issue-label relation. -func NewIssueLabel(issue *Issue, label *Label, doer *user_model.User) (err error) { - if HasIssueLabel(db.DefaultContext, issue.ID, label.ID) { - return nil - } - - ctx, committer, err := db.TxContext(db.DefaultContext) - if err != nil { - return err - } - defer committer.Close() - - if err = issue.LoadRepo(ctx); err != nil { - return err - } - - // Do NOT add invalid labels - if issue.RepoID != label.RepoID && issue.Repo.OwnerID != label.OrgID { - return nil - } - - if err = RemoveDuplicateExclusiveIssueLabels(ctx, issue, label, doer); err != nil { - return nil - } - - if err = newIssueLabel(ctx, issue, label, doer); err != nil { - return err - } - - issue.Labels = nil - if err = issue.LoadLabels(ctx); err != nil { - return err - } - - return committer.Commit() -} - -// newIssueLabels add labels to an issue. It will check if the labels are valid for the issue -func newIssueLabels(ctx context.Context, issue *Issue, labels []*Label, doer *user_model.User) (err error) { - if err = issue.LoadRepo(ctx); err != nil { - return err - } - for _, l := range labels { - // Don't add already present labels and invalid labels - if HasIssueLabel(ctx, issue.ID, l.ID) || - (l.RepoID != issue.RepoID && l.OrgID != issue.Repo.OwnerID) { - continue - } - - if err = newIssueLabel(ctx, issue, l, doer); err != nil { - return fmt.Errorf("newIssueLabel: %w", err) - } - } - - return nil -} - -// NewIssueLabels creates a list of issue-label relations. -func NewIssueLabels(issue *Issue, labels []*Label, doer *user_model.User) (err error) { - ctx, committer, err := db.TxContext(db.DefaultContext) - if err != nil { - return err - } - defer committer.Close() - - if err = newIssueLabels(ctx, issue, labels, doer); err != nil { - return err - } - - issue.Labels = nil - if err = issue.LoadLabels(ctx); err != nil { - return err - } - - return committer.Commit() -} - -func deleteIssueLabel(ctx context.Context, issue *Issue, label *Label, doer *user_model.User) (err error) { - if count, err := db.DeleteByBean(ctx, &IssueLabel{ - IssueID: issue.ID, - LabelID: label.ID, - }); err != nil { - return err - } else if count == 0 { - return nil - } - - if err = issue.LoadRepo(ctx); err != nil { - return - } - - opts := &CreateCommentOptions{ - Type: CommentTypeLabel, - Doer: doer, - Repo: issue.Repo, - Issue: issue, - Label: label, - } - if _, err = CreateComment(ctx, opts); err != nil { - return err - } - - return updateLabelCols(ctx, label, "num_issues", "num_closed_issue") -} - -// DeleteIssueLabel deletes issue-label relation. -func DeleteIssueLabel(ctx context.Context, issue *Issue, label *Label, doer *user_model.User) error { - if err := deleteIssueLabel(ctx, issue, label, doer); err != nil { - return err - } - - issue.Labels = nil - return issue.LoadLabels(ctx) -} - -// DeleteLabelsByRepoID deletes labels of some repository -func DeleteLabelsByRepoID(ctx context.Context, repoID int64) error { - deleteCond := builder.Select("id").From("label").Where(builder.Eq{"label.repo_id": repoID}) - - if _, err := db.GetEngine(ctx).In("label_id", deleteCond). - Delete(&IssueLabel{}); err != nil { - return err - } - - _, err := db.DeleteByBean(ctx, &Label{RepoID: repoID}) - return err -} - -// CountOrphanedLabels return count of labels witch are broken and not accessible via ui anymore -func CountOrphanedLabels(ctx context.Context) (int64, error) { - noref, err := db.GetEngine(ctx).Table("label").Where("repo_id=? AND org_id=?", 0, 0).Count() - if err != nil { - return 0, err - } - - norepo, err := db.GetEngine(ctx).Table("label"). - Where(builder.And( - builder.Gt{"repo_id": 0}, - builder.NotIn("repo_id", builder.Select("id").From("`repository`")), - )). - Count() - if err != nil { - return 0, err - } - - noorg, err := db.GetEngine(ctx).Table("label"). - Where(builder.And( - builder.Gt{"org_id": 0}, - builder.NotIn("org_id", builder.Select("id").From("`user`")), - )). - Count() - if err != nil { - return 0, err - } - - return noref + norepo + noorg, nil -} - -// DeleteOrphanedLabels delete labels witch are broken and not accessible via ui anymore -func DeleteOrphanedLabels(ctx context.Context) error { - // delete labels with no reference - if _, err := db.GetEngine(ctx).Table("label").Where("repo_id=? AND org_id=?", 0, 0).Delete(new(Label)); err != nil { - return err - } - - // delete labels with none existing repos - if _, err := db.GetEngine(ctx). - Where(builder.And( - builder.Gt{"repo_id": 0}, - builder.NotIn("repo_id", builder.Select("id").From("`repository`")), - )). - Delete(Label{}); err != nil { - return err - } - - // delete labels with none existing orgs - if _, err := db.GetEngine(ctx). - Where(builder.And( - builder.Gt{"org_id": 0}, - builder.NotIn("org_id", builder.Select("id").From("`user`")), - )). - Delete(Label{}); err != nil { - return err - } - - return nil -} - -// CountOrphanedIssueLabels return count of IssueLabels witch have no label behind anymore -func CountOrphanedIssueLabels(ctx context.Context) (int64, error) { - return db.GetEngine(ctx).Table("issue_label"). - NotIn("label_id", builder.Select("id").From("label")). - Count() -} - -// DeleteOrphanedIssueLabels delete IssueLabels witch have no label behind anymore -func DeleteOrphanedIssueLabels(ctx context.Context) error { - _, err := db.GetEngine(ctx). - NotIn("label_id", builder.Select("id").From("label")). - Delete(IssueLabel{}) - return err -} - -// CountIssueLabelWithOutsideLabels count label comments with outside label -func CountIssueLabelWithOutsideLabels(ctx context.Context) (int64, error) { - return db.GetEngine(ctx).Where(builder.Expr("(label.org_id = 0 AND issue.repo_id != label.repo_id) OR (label.repo_id = 0 AND label.org_id != repository.owner_id)")). - Table("issue_label"). - Join("inner", "label", "issue_label.label_id = label.id "). - Join("inner", "issue", "issue.id = issue_label.issue_id "). - Join("inner", "repository", "issue.repo_id = repository.id"). - Count(new(IssueLabel)) -} - -// FixIssueLabelWithOutsideLabels fix label comments with outside label -func FixIssueLabelWithOutsideLabels(ctx context.Context) (int64, error) { - res, err := db.GetEngine(ctx).Exec(`DELETE FROM issue_label WHERE issue_label.id IN ( - SELECT il_too.id FROM ( - SELECT il_too_too.id - FROM issue_label AS il_too_too - INNER JOIN label ON il_too_too.label_id = label.id - INNER JOIN issue on issue.id = il_too_too.issue_id - INNER JOIN repository on repository.id = issue.repo_id - WHERE - (label.org_id = 0 AND issue.repo_id != label.repo_id) OR (label.repo_id = 0 AND label.org_id != repository.owner_id) - ) AS il_too )`) - if err != nil { - return 0, err - } - - return res.RowsAffected() -} diff --git a/modules/indexer/issues/indexer.go b/modules/indexer/issues/indexer.go index e88b1b2befa75..76ff80ffca7c4 100644 --- a/modules/indexer/issues/indexer.go +++ b/modules/indexer/issues/indexer.go @@ -302,7 +302,7 @@ func populateIssueIndexer(ctx context.Context) { // UpdateRepoIndexer add/update all issues of the repositories func UpdateRepoIndexer(ctx context.Context, repo *repo_model.Repository) { is, err := issues_model.Issues(ctx, &issues_model.IssuesOptions{ - RepoID: repo.ID, + RepoIDs: []int64{repo.ID}, IsClosed: util.OptionalBoolNone, IsPull: util.OptionalBoolNone, }) diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index 5bf5fc8c8bcca..95528d664d7b9 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -470,7 +470,7 @@ func ListIssues(ctx *context.APIContext) { if len(keyword) == 0 || len(issueIDs) > 0 || len(labelIDs) > 0 { issuesOpt := &issues_model.IssuesOptions{ ListOptions: listOptions, - RepoID: ctx.Repo.Repository.ID, + RepoIDs: []int64{ctx.Repo.Repository.ID}, IsClosed: isClosed, IssueIDs: issueIDs, LabelIDs: labelIDs, diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index cb0aaa3db512e..88d2a97a7ad9c 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -207,7 +207,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption uti issueStats = &issues_model.IssueStats{} } else { issueStats, err = issues_model.GetIssueStats(&issues_model.IssuesOptions{ - RepoID: repo.ID, + RepoIDs: []int64{repo.ID}, LabelIDs: labelIDs, MilestoneIDs: []int64{milestoneID}, ProjectID: projectID, @@ -258,7 +258,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption uti Page: pager.Paginater.Current(), PageSize: setting.UI.IssuePagingNum, }, - RepoID: repo.ID, + RepoIDs: []int64{repo.ID}, AssigneeID: assigneeID, PosterID: posterID, MentionedID: mentionedID, @@ -2652,7 +2652,7 @@ func ListIssues(ctx *context.Context) { if len(keyword) == 0 || len(issueIDs) > 0 || len(labelIDs) > 0 { issuesOpt := &issues_model.IssuesOptions{ ListOptions: listOptions, - RepoID: ctx.Repo.Repository.ID, + RepoIDs: []int64{ctx.Repo.Repository.ID}, IsClosed: isClosed, IssueIDs: issueIDs, LabelIDs: labelIDs, diff --git a/routers/web/user/home.go b/routers/web/user/home.go index 1af56e24b046f..2513fc9a98383 100644 --- a/routers/web/user/home.go +++ b/routers/web/user/home.go @@ -521,10 +521,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { // Parse ctx.FormString("repos") and remember matched repo IDs for later. // Gets set when clicking filters on the issues overview page. - repoIDs := getRepoIDs(ctx.FormString("repos")) - if len(repoIDs) > 0 { - opts.RepoCond = builder.In("issue.repo_id", repoIDs) - } + opts.RepoIDs = getRepoIDs(ctx.FormString("repos")) // ------------------------------ // Get issues as defined by opts. @@ -580,11 +577,10 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { // ------------------------------- var issueStats *issues_model.IssueStats if !forceEmpty { - statsOpts := issues_model.UserIssueStatsOptions{ - UserID: ctx.Doer.ID, - FilterMode: filterMode, - IsPull: isPullList, - IsClosed: isShowClosed, + statsOpts := issues_model.IssuesOptions{ + User: ctx.Doer, + IsPull: util.OptionalBoolOf(isPullList), + IsClosed: util.OptionalBoolOf(isShowClosed), IssueIDs: issueIDsFromSearch, IsArchived: util.OptionalBoolFalse, LabelIDs: opts.LabelIDs, @@ -593,7 +589,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { RepoCond: opts.RepoCond, } - issueStats, err = issues_model.GetUserIssueStats(statsOpts) + issueStats, err = issues_model.GetUserIssueStats(filterMode, statsOpts) if err != nil { ctx.ServerError("GetUserIssueStats Shown", err) return @@ -609,9 +605,9 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { } else { shownIssues = int(issueStats.ClosedCount) } - if len(repoIDs) != 0 { + if len(opts.RepoIDs) != 0 { shownIssues = 0 - for _, repoID := range repoIDs { + for _, repoID := range opts.RepoIDs { shownIssues += int(issueCountByRepo[repoID]) } } @@ -622,8 +618,8 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { } ctx.Data["TotalIssueCount"] = allIssueCount - if len(repoIDs) == 1 { - repo := showReposMap[repoIDs[0]] + if len(opts.RepoIDs) == 1 { + repo := showReposMap[opts.RepoIDs[0]] if repo != nil { ctx.Data["SingleRepoLink"] = repo.Link() } @@ -665,7 +661,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { ctx.Data["IssueStats"] = issueStats ctx.Data["ViewType"] = viewType ctx.Data["SortType"] = sortType - ctx.Data["RepoIDs"] = repoIDs + ctx.Data["RepoIDs"] = opts.RepoIDs ctx.Data["IsShowClosed"] = isShowClosed ctx.Data["SelectLabels"] = selectedLabels @@ -676,7 +672,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { } // Convert []int64 to string - reposParam, _ := json.Marshal(repoIDs) + reposParam, _ := json.Marshal(opts.RepoIDs) ctx.Data["ReposParam"] = string(reposParam) diff --git a/services/migrations/gitea_uploader_test.go b/services/migrations/gitea_uploader_test.go index b59ccb7c4415f..878b6d6b8453f 100644 --- a/services/migrations/gitea_uploader_test.go +++ b/services/migrations/gitea_uploader_test.go @@ -104,7 +104,7 @@ func TestGiteaUploadRepo(t *testing.T) { assert.Len(t, releases, 1) issues, err := issues_model.Issues(db.DefaultContext, &issues_model.IssuesOptions{ - RepoID: repo.ID, + RepoIDs: []int64{repo.ID}, IsPull: util.OptionalBoolFalse, SortType: "oldest", }) From f5ce2ed292a90041abd749a8db26671645648a43 Mon Sep 17 00:00:00 2001 From: Yarden Shoham Date: Fri, 19 May 2023 18:17:07 +0300 Subject: [PATCH 05/27] Allow all URL schemes in Markdown links by default (#24805) - Closes #21146 - Closes #16721 ## :warning: BREAKING :warning: This changes the default behavior to now create links for any URL scheme when the user uses the markdown form for links (`[label](URL)`), this doesn't affect the rendering of inline links. To opt-out set the `markdown.CUSTOM_URL_SCHEMES` setting to a list of allowed schemes, all other schemes (except `http` and `https`) won't be allowed. # Before ![image](https://github.com/go-gitea/gitea/assets/20454870/35fa18ce-7dda-4995-b5b3-3f360f38296d) # After ![image](https://github.com/go-gitea/gitea/assets/20454870/0922216b-0b35-4b77-9919-21a5c21dd5d0) --------- Signed-off-by: Yarden Shoham Co-authored-by: Giteabot --- custom/conf/app.example.ini | 1 + .../doc/administration/config-cheat-sheet.en-us.md | 2 +- go.mod | 5 ++--- go.sum | 8 ++++---- modules/markup/sanitizer.go | 7 ++++++- modules/markup/sanitizer_test.go | 4 ++++ 6 files changed, 18 insertions(+), 9 deletions(-) diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 9046f5473447f..91d829c0ca955 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -1327,6 +1327,7 @@ ROUTER = console ;; Comma separated list of custom URL-Schemes that are allowed as links when rendering Markdown ;; for example git,magnet,ftp (more at https://en.wikipedia.org/wiki/List_of_URI_schemes) ;; URLs starting with http and https are always displayed, whatever is put in this entry. +;; If this entry is empty, all URL schemes are allowed. ;CUSTOM_URL_SCHEMES = ;; ;; List of file extensions that should be rendered/edited as Markdown diff --git a/docs/content/doc/administration/config-cheat-sheet.en-us.md b/docs/content/doc/administration/config-cheat-sheet.en-us.md index 5bf5d6fbf9d62..fdd1235f89793 100644 --- a/docs/content/doc/administration/config-cheat-sheet.en-us.md +++ b/docs/content/doc/administration/config-cheat-sheet.en-us.md @@ -276,7 +276,7 @@ The following configuration set `Content-Type: application/vnd.android.package-a trailing whitespace to paragraphs is not necessary to force a line break. - `CUSTOM_URL_SCHEMES`: Use a comma separated list (ftp,git,svn) to indicate additional URL hyperlinks to be rendered in Markdown. URLs beginning in http and https are - always displayed + always displayed. If this entry is empty, all URL schemes are allowed - `FILE_EXTENSIONS`: **.md,.markdown,.mdown,.mkd,.livemd**: List of file extensions that should be rendered/edited as Markdown. Separate the extensions with a comma. To render files without any extension as markdown, just put a comma. - `ENABLE_MATH`: **true**: Enables detection of `\(...\)`, `\[...\]`, `$...$` and `$$...$$` blocks as math blocks. diff --git a/go.mod b/go.mod index 24765df181344..93b8052daf86f 100644 --- a/go.mod +++ b/go.mod @@ -76,7 +76,7 @@ require ( github.com/mattn/go-sqlite3 v1.14.16 github.com/meilisearch/meilisearch-go v0.24.0 github.com/mholt/archiver/v3 v3.5.1 - github.com/microcosm-cc/bluemonday v1.0.23 + github.com/microcosm-cc/bluemonday v1.0.24 github.com/minio/minio-go/v7 v7.0.52 github.com/minio/sha256-simd v1.0.0 github.com/msteinert/pam v1.1.0 @@ -109,7 +109,7 @@ require ( github.com/yuin/goldmark-meta v1.1.0 golang.org/x/crypto v0.8.0 golang.org/x/image v0.7.0 - golang.org/x/net v0.9.0 + golang.org/x/net v0.10.0 golang.org/x/oauth2 v0.7.0 golang.org/x/sys v0.8.0 golang.org/x/text v0.9.0 @@ -288,7 +288,6 @@ require ( go.uber.org/zap v1.24.0 // indirect golang.org/x/mod v0.10.0 // indirect golang.org/x/sync v0.2.0 // indirect - golang.org/x/term v0.8.0 // indirect golang.org/x/time v0.3.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/genproto v0.0.0-20230306155012-7f2fa6fef1f4 // indirect diff --git a/go.sum b/go.sum index 62c01c690af47..8e5b728aac9de 100644 --- a/go.sum +++ b/go.sum @@ -876,8 +876,8 @@ github.com/mholt/acmez v1.1.0 h1:IQ9CGHKOHokorxnffsqDvmmE30mDenO1lptYZ1AYkHY= github.com/mholt/acmez v1.1.0/go.mod h1:zwo5+fbLLTowAX8o8ETfQzbDtwGEXnPhkmGdKIP+bgs= github.com/mholt/archiver/v3 v3.5.1 h1:rDjOBX9JSF5BvoJGvjqK479aL70qh9DIpZCl+k7Clwo= github.com/mholt/archiver/v3 v3.5.1/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssnDhppzS1L4= -github.com/microcosm-cc/bluemonday v1.0.23 h1:SMZe2IGa0NuHvnVNAZ+6B38gsTbi5e4sViiWJyDDqFY= -github.com/microcosm-cc/bluemonday v1.0.23/go.mod h1:mN70sk7UkkF8TUr2IGBpNN0jAgStuPzlK76QuruE/z4= +github.com/microcosm-cc/bluemonday v1.0.24 h1:NGQoPtwGVcbGkKfvyYk1yRqknzBuoMiUrO6R7uFTPlw= +github.com/microcosm-cc/bluemonday v1.0.24/go.mod h1:ArQySAMps0790cHSkdPEJ7bGkF2VePWH773hsJNSHf8= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/dns v1.1.54 h1:5jon9mWcb0sFJGpnI99tOMhCPyJ+RPVz5b63MQG0VWI= github.com/miekg/dns v1.1.54/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY= @@ -1418,8 +1418,9 @@ golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= -golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= +golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -1543,7 +1544,6 @@ golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/term v0.8.0 h1:n5xxQn2i3PC0yLAbjTpNT85q/Kgzcr2gIoX9OrJUols= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/modules/markup/sanitizer.go b/modules/markup/sanitizer.go index 600ccbf3c6126..a0c9ee171f699 100644 --- a/modules/markup/sanitizer.go +++ b/modules/markup/sanitizer.go @@ -22,7 +22,10 @@ type Sanitizer struct { init sync.Once } -var sanitizer = &Sanitizer{} +var ( + sanitizer = &Sanitizer{} + allowAllRegex = regexp.MustCompile(".+") +) // NewSanitizer initializes sanitizer with allowed attributes based on settings. // Multiple calls to this function will only create one instance of Sanitizer during @@ -74,6 +77,8 @@ func createDefaultPolicy() *bluemonday.Policy { // Custom URL-Schemes if len(setting.Markdown.CustomURLSchemes) > 0 { policy.AllowURLSchemes(setting.Markdown.CustomURLSchemes...) + } else { + policy.AllowURLSchemesMatching(allowAllRegex) } // Allow classes for anchors diff --git a/modules/markup/sanitizer_test.go b/modules/markup/sanitizer_test.go index c792ec2dc629d..0c22ce3ba0e78 100644 --- a/modules/markup/sanitizer_test.go +++ b/modules/markup/sanitizer_test.go @@ -52,6 +52,10 @@ func Test_Sanitizer(t *testing.T) { `Hello World`, `Hello World`, `

Hello World

`, `

Hello World

`, `Hello World`, `Hello World`, + + // URLs + `[my custom URL scheme](cbthunderlink://somebase64string)`, `[my custom URL scheme](cbthunderlink://somebase64string)`, + `[my custom URL scheme](matrix:roomid/psumPMeAfzgAeQpXMG:feneas.org?action=join)`, `[my custom URL scheme](matrix:roomid/psumPMeAfzgAeQpXMG:feneas.org?action=join)`, } for i := 0; i < len(testCases); i += 2 { From acde12a8a276cd3c0bb6186571b5da2f1f98757c Mon Sep 17 00:00:00 2001 From: HesterG Date: Sat, 20 May 2023 00:02:34 +0800 Subject: [PATCH 06/27] Fix max width and margin of comment box on conversation page (#24809) Fix regression from #23937 The changes should only be limited to `.conversation-holder .comment-code-cloud`, otherwise it will affect the `.comment-code-cloud` in conversation tab Before: Screen Shot 2023-05-19 at 18 22 25 After: Screen Shot 2023-05-19 at 18 35 01 --------- Co-authored-by: Giteabot --- web_src/css/review.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/web_src/css/review.css b/web_src/css/review.css index 14668eb24599a..d57fcc85df237 100644 --- a/web_src/css/review.css +++ b/web_src/css/review.css @@ -61,6 +61,9 @@ .comment-code-cloud { padding: 0.5rem 1rem !important; position: relative; +} + +.conversation-holder .comment-code-cloud { max-width: 820px; } From a103b79f60948fa8ee44f95304716f3d354a8c15 Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 19 May 2023 18:30:24 +0200 Subject: [PATCH 07/27] Rework label colors (#24790) Introduce `--color-label-fg`, `--color-label-bg` and `--color-label-hover-bg`, decoupling the label styles from other color variables. I've set the colors so that non-interactive labels like on tabs are dark-on-light on light theme, which imho looks better than previous light-on-dark. In the screenshot below, the leftmost label has hover, the second one has active. Screenshot 2023-05-18 at 12 48 26 Screenshot 2023-05-18 at 13 04 07 --------- Co-authored-by: Giteabot --- web_src/css/base.css | 43 ++++++++++++++------------ web_src/css/themes/theme-arc-green.css | 7 ++++- 2 files changed, 29 insertions(+), 21 deletions(-) diff --git a/web_src/css/base.css b/web_src/css/base.css index bcf7b0811b819..36624ab957628 100644 --- a/web_src/css/base.css +++ b/web_src/css/base.css @@ -180,10 +180,13 @@ --color-caret: var(--color-text-dark); --color-reaction-bg: #0000000a; --color-reaction-active-bg: var(--color-primary-alpha-20); - --color-tooltip-bg: #000000f0; --color-tooltip-text: #ffffff; + --color-tooltip-bg: #000000f0; --color-header-bar: #ffffff; - --color-label-active-bg: #d0d0d0; + --color-label-text: #232323; + --color-label-bg: #cacaca5b; + --color-label-hover-bg: #cacacaa0; + --color-label-active-bg: #cacacaff; --color-accent: var(--color-primary-light-1); --color-small-accent: var(--color-primary-light-6); --color-active-line: #fffbdd; @@ -820,16 +823,6 @@ a.label, margin-right: 0.35em; } -.ui.menu .item > .label { - background: var(--color-grey); -} - -.ui.active.label { - background: var(--color-label-active-bg); - border-color: var(--color-label-active-bg); - color: var(--color-text-dark); -} - .ui.menu .dropdown.item:hover, .ui.menu a.item:hover { color: var(--color-text); @@ -1976,22 +1969,32 @@ i.icon.centerlock { .ui.label { padding: 0.3em 0.5em; - background: var(--color-light); - color: var(--color-text-light); +} + +.ui.label, +.ui.menu .item > .label { + background: var(--color-label-bg); + color: var(--color-label-text); +} + +.ui.active.label { + background: var(--color-label-active-bg); + border-color: var(--color-label-active-bg); + color: var(--color-label-text); } .ui.labels a.label:hover, a.ui.label:hover { - background: var(--color-hover); - border-color: var(--color-hover); - color: var(--color-text); + background: var(--color-label-hover-bg); + border-color: var(--color-label-hover-bg); + color: var(--color-label-text); } .ui.labels a.active.label:hover, a.ui.active.label:hover { - background: var(--color-active); - border-color: var(--color-active); - color: var(--color-text); + background: var(--color-label-active-bg); + border-color: var(--color-label-active-bg); + color: var(--color-label-text); } .ui.label > .detail .icons { diff --git a/web_src/css/themes/theme-arc-green.css b/web_src/css/themes/theme-arc-green.css index 84007d2e2dfe5..d5db18b55ae56 100644 --- a/web_src/css/themes/theme-arc-green.css +++ b/web_src/css/themes/theme-arc-green.css @@ -165,8 +165,13 @@ --color-caret: var(--color-text); /* should ideally be --color-text-dark, see #15651 */ --color-reaction-bg: #ffffff12; --color-reaction-active-bg: var(--color-primary-alpha-40); + --color-tooltip-text: #ffffff; + --color-tooltip-bg: #000000f0; --color-header-bar: #2e323e; - --color-label-active-bg: #4c525e; + --color-label-text: #dfe3ec; + --color-label-bg: #7c84974b; + --color-label-hover-bg: #7c8497a0; + --color-label-active-bg: #7c8497ff; --color-accent: var(--color-primary-light-1); --color-small-accent: var(--color-primary-light-5); --color-active-line: #534d1b; From c641a22f2a7f5c404accb67f6aea4a000f7f6e80 Mon Sep 17 00:00:00 2001 From: Yarden Shoham Date: Fri, 19 May 2023 20:03:09 +0300 Subject: [PATCH 08/27] Mute repo names in dashboard repo list (#24811) # Before ![image](https://github.com/go-gitea/gitea/assets/20454870/24b80212-4a4d-44a7-99d5-a8c6b207225e) # After ![image](https://github.com/go-gitea/gitea/assets/20454870/565b242a-f65d-450c-b43b-c4539a0f8b28) Signed-off-by: Yarden Shoham Co-authored-by: Giteabot --- web_src/js/components/DashboardRepoList.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web_src/js/components/DashboardRepoList.vue b/web_src/js/components/DashboardRepoList.vue index 84ee8866181ab..a434eee436e7a 100644 --- a/web_src/js/components/DashboardRepoList.vue +++ b/web_src/js/components/DashboardRepoList.vue @@ -71,7 +71,7 @@
  • - +
    {{ repo.full_name }}
    From 3288252ddaaab03531bfef9d567a9921efedae80 Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 19 May 2023 22:12:30 +0200 Subject: [PATCH 09/27] Fix duplicate tooltip hiding (#24814) A tippy instance's role is actually on `props.role`. This makes duplicate tooltip hiding work again after https://github.com/go-gitea/gitea/pull/24688. --- web_src/js/modules/tippy.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web_src/js/modules/tippy.js b/web_src/js/modules/tippy.js index abae3d33d07d0..df2143b0ef184 100644 --- a/web_src/js/modules/tippy.js +++ b/web_src/js/modules/tippy.js @@ -20,7 +20,7 @@ export function createTippy(target, opts = {}) { onShow: (instance) => { // hide other tooltip instances so only one tooltip shows at a time for (const visibleInstance of visibleInstances) { - if (visibleInstance.role === 'tooltip') { + if (visibleInstance.props.role === 'tooltip') { visibleInstance.hide(); } } From bbc1456542b7830f8abe4feb5975e57d24327970 Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 19 May 2023 23:58:12 +0200 Subject: [PATCH 10/27] Update JS dependencies (#24815) - Update all JS dependencies - Remove `@vue/compiler-sfc` as per [this notice](https://github.com/vuejs/core/tree/main/packages/compiler-sfc#vuecompiler-sfc), still builds as normal - Tested build and text/image copy --- package-lock.json | 618 ++++++++++++++++++++++------------------------ package.json | 19 +- 2 files changed, 309 insertions(+), 328 deletions(-) diff --git a/package-lock.json b/package-lock.json index c9d4bca3d771f..a28539c571342 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,13 +17,12 @@ "@github/text-expander-element": "2.3.0", "@mcaptcha/vanilla-glue": "0.1.0-alpha-3", "@primer/octicons": "19.1.0", - "@vue/compiler-sfc": "3.3.2", "@webcomponents/custom-elements": "1.6.0", "add-asset-webpack-plugin": "2.0.1", "ansi-to-html": "0.7.2", - "asciinema-player": "3.3.0", - "clippie": "3.1.4", - "css-loader": "6.7.3", + "asciinema-player": "3.4.0", + "clippie": "4.0.1", + "css-loader": "6.7.4", "dropzone": "6.0.0-beta.2", "easymde": "2.18.0", "esbuild-loader": "3.0.1", @@ -34,7 +33,7 @@ "katex": "0.16.7", "license-checker-webpack-plugin": "0.2.1", "mermaid": "10.1.0", - "mini-css-extract-plugin": "2.7.5", + "mini-css-extract-plugin": "2.7.6", "minimatch": "9.0.0", "monaco-editor": "0.38.0", "monaco-editor-webpack-plugin": "7.0.1", @@ -45,11 +44,11 @@ "tippy.js": "6.3.7", "tributejs": "5.1.3", "uint8-to-base64": "0.2.0", - "vue": "3.3.2", + "vue": "3.3.4", "vue-bar-graph": "2.0.0", "vue-loader": "17.1.1", "vue3-calendar-heatmap": "2.0.5", - "webpack": "5.82.1", + "webpack": "5.83.1", "webpack-cli": "5.1.1", "workbox-routing": "6.5.4", "workbox-strategies": "6.5.4", @@ -71,15 +70,15 @@ "eslint-plugin-regexp": "1.15.0", "eslint-plugin-sonarjs": "0.19.0", "eslint-plugin-unicorn": "47.0.0", - "eslint-plugin-vue": "9.12.0", + "eslint-plugin-vue": "9.13.0", "eslint-plugin-wc": "1.5.0", "jsdom": "22.0.0", "markdownlint-cli": "0.34.0", - "stylelint": "15.6.1", + "stylelint": "15.6.2", "stylelint-declaration-strict-value": "1.9.2", "svgo": "3.0.2", "updates": "14.1.0", - "vitest": "0.31.0" + "vitest": "0.31.1" }, "engines": { "node": ">= 16.0.0" @@ -170,15 +169,6 @@ "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", "dev": true }, - "node_modules/@babel/highlight/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/@babel/highlight/node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", @@ -441,9 +431,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.18.tgz", - "integrity": "sha512-EmwL+vUBZJ7mhFCs5lA4ZimpUH3WMAoqvOIYhVQwdIgSpHC8ImHdsRyhHAVxpDYUSm0lWvd63z0XH1IlImS2Qw==", + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.19.tgz", + "integrity": "sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==", "cpu": [ "arm" ], @@ -456,9 +446,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.18.tgz", - "integrity": "sha512-/iq0aK0eeHgSC3z55ucMAHO05OIqmQehiGay8eP5l/5l+iEr4EIbh4/MI8xD9qRFjqzgkc0JkX0LculNC9mXBw==", + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.19.tgz", + "integrity": "sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==", "cpu": [ "arm64" ], @@ -471,9 +461,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.18.tgz", - "integrity": "sha512-x+0efYNBF3NPW2Xc5bFOSFW7tTXdAcpfEg2nXmxegm4mJuVeS+i109m/7HMiOQ6M12aVGGFlqJX3RhNdYM2lWg==", + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.19.tgz", + "integrity": "sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==", "cpu": [ "x64" ], @@ -486,9 +476,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.18.tgz", - "integrity": "sha512-6tY+djEAdF48M1ONWnQb1C+6LiXrKjmqjzPNPWXhu/GzOHTHX2nh8Mo2ZAmBFg0kIodHhciEgUBtcYCAIjGbjQ==", + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.19.tgz", + "integrity": "sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==", "cpu": [ "arm64" ], @@ -501,9 +491,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.18.tgz", - "integrity": "sha512-Qq84ykvLvya3dO49wVC9FFCNUfSrQJLbxhoQk/TE1r6MjHo3sFF2tlJCwMjhkBVq3/ahUisj7+EpRSz0/+8+9A==", + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.19.tgz", + "integrity": "sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==", "cpu": [ "x64" ], @@ -516,9 +506,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.18.tgz", - "integrity": "sha512-fw/ZfxfAzuHfaQeMDhbzxp9mc+mHn1Y94VDHFHjGvt2Uxl10mT4CDavHm+/L9KG441t1QdABqkVYwakMUeyLRA==", + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.19.tgz", + "integrity": "sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==", "cpu": [ "arm64" ], @@ -531,9 +521,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.18.tgz", - "integrity": "sha512-FQFbRtTaEi8ZBi/A6kxOC0V0E9B/97vPdYjY9NdawyLd4Qk5VD5g2pbWN2VR1c0xhzcJm74HWpObPszWC+qTew==", + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.19.tgz", + "integrity": "sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==", "cpu": [ "x64" ], @@ -546,9 +536,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.18.tgz", - "integrity": "sha512-jW+UCM40LzHcouIaqv3e/oRs0JM76JfhHjCavPxMUti7VAPh8CaGSlS7cmyrdpzSk7A+8f0hiedHqr/LMnfijg==", + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.19.tgz", + "integrity": "sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==", "cpu": [ "arm" ], @@ -561,9 +551,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.18.tgz", - "integrity": "sha512-R7pZvQZFOY2sxUG8P6A21eq6q+eBv7JPQYIybHVf1XkQYC+lT7nDBdC7wWKTrbvMXKRaGudp/dzZCwL/863mZQ==", + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.19.tgz", + "integrity": "sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==", "cpu": [ "arm64" ], @@ -576,9 +566,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.18.tgz", - "integrity": "sha512-ygIMc3I7wxgXIxk6j3V00VlABIjq260i967Cp9BNAk5pOOpIXmd1RFQJQX9Io7KRsthDrQYrtcx7QCof4o3ZoQ==", + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.19.tgz", + "integrity": "sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==", "cpu": [ "ia32" ], @@ -591,9 +581,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.18.tgz", - "integrity": "sha512-bvPG+MyFs5ZlwYclCG1D744oHk1Pv7j8psF5TfYx7otCVmcJsEXgFEhQkbhNW8otDHL1a2KDINW20cfCgnzgMQ==", + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.19.tgz", + "integrity": "sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==", "cpu": [ "loong64" ], @@ -606,9 +596,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.18.tgz", - "integrity": "sha512-oVqckATOAGuiUOa6wr8TXaVPSa+6IwVJrGidmNZS1cZVx0HqkTMkqFGD2HIx9H1RvOwFeWYdaYbdY6B89KUMxA==", + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.19.tgz", + "integrity": "sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==", "cpu": [ "mips64el" ], @@ -621,9 +611,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.18.tgz", - "integrity": "sha512-3dLlQO+b/LnQNxgH4l9rqa2/IwRJVN9u/bK63FhOPB4xqiRqlQAU0qDU3JJuf0BmaH0yytTBdoSBHrb2jqc5qQ==", + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.19.tgz", + "integrity": "sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==", "cpu": [ "ppc64" ], @@ -636,9 +626,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.18.tgz", - "integrity": "sha512-/x7leOyDPjZV3TcsdfrSI107zItVnsX1q2nho7hbbQoKnmoeUWjs+08rKKt4AUXju7+3aRZSsKrJtaRmsdL1xA==", + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.19.tgz", + "integrity": "sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==", "cpu": [ "riscv64" ], @@ -651,9 +641,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.18.tgz", - "integrity": "sha512-cX0I8Q9xQkL/6F5zWdYmVf5JSQt+ZfZD2bJudZrWD+4mnUvoZ3TDDXtDX2mUaq6upMFv9FlfIh4Gfun0tbGzuw==", + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.19.tgz", + "integrity": "sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==", "cpu": [ "s390x" ], @@ -666,9 +656,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.18.tgz", - "integrity": "sha512-66RmRsPlYy4jFl0vG80GcNRdirx4nVWAzJmXkevgphP1qf4dsLQCpSKGM3DUQCojwU1hnepI63gNZdrr02wHUA==", + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.19.tgz", + "integrity": "sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==", "cpu": [ "x64" ], @@ -681,9 +671,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.18.tgz", - "integrity": "sha512-95IRY7mI2yrkLlTLb1gpDxdC5WLC5mZDi+kA9dmM5XAGxCME0F8i4bYH4jZreaJ6lIZ0B8hTrweqG1fUyW7jbg==", + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.19.tgz", + "integrity": "sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==", "cpu": [ "x64" ], @@ -696,9 +686,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.18.tgz", - "integrity": "sha512-WevVOgcng+8hSZ4Q3BKL3n1xTv5H6Nb53cBrtzzEjDbbnOmucEVcZeGCsCOi9bAOcDYEeBZbD2SJNBxlfP3qiA==", + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.19.tgz", + "integrity": "sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==", "cpu": [ "x64" ], @@ -711,9 +701,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.18.tgz", - "integrity": "sha512-Rzf4QfQagnwhQXVBS3BYUlxmEbcV7MY+BH5vfDZekU5eYpcffHSyjU8T0xucKVuOcdCsMo+Ur5wmgQJH2GfNrg==", + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.19.tgz", + "integrity": "sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==", "cpu": [ "x64" ], @@ -726,9 +716,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.18.tgz", - "integrity": "sha512-Kb3Ko/KKaWhjeAm2YoT/cNZaHaD1Yk/pa3FTsmqo9uFh1D1Rfco7BBLIPdDOozrObj2sahslFuAQGvWbgWldAg==", + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.19.tgz", + "integrity": "sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==", "cpu": [ "arm64" ], @@ -741,9 +731,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.18.tgz", - "integrity": "sha512-0/xUMIdkVHwkvxfbd5+lfG7mHOf2FRrxNbPiKWg9C4fFrB8H0guClmaM3BFiRUYrznVoyxTIyC/Ou2B7QQSwmw==", + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.19.tgz", + "integrity": "sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==", "cpu": [ "ia32" ], @@ -756,9 +746,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.18.tgz", - "integrity": "sha512-qU25Ma1I3NqTSHJUOKi9sAH1/Mzuvlke0ioMJRthLXKm7JiSKVwFghlGbDLOO2sARECGhja4xYfRAZNPAkooYg==", + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.19.tgz", + "integrity": "sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==", "cpu": [ "x64" ], @@ -786,15 +776,6 @@ "eslint": ">=4.19.1" } }, - "node_modules/@eslint-community/eslint-plugin-eslint-comments/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -1618,9 +1599,9 @@ } }, "node_modules/@stoplight/spectral-ruleset-bundler": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@stoplight/spectral-ruleset-bundler/-/spectral-ruleset-bundler-1.5.1.tgz", - "integrity": "sha512-gvlBXkyxLBNdslN/5HEYvCqMr0dvKQwJbbJCKbOvvRTZhhPVzmLb7yavWXjOnRhYn6IAhGAs7u4yhxn7cX/FtQ==", + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-ruleset-bundler/-/spectral-ruleset-bundler-1.5.2.tgz", + "integrity": "sha512-4QUVUFAU+S7IQ9XeCu+0TQMYxKFpKnkOAfa9unRQ1iPL2cviaipEN6witpbAptdHJD3UUjx4OnwlX8WwmXSq9w==", "dev": true, "dependencies": { "@rollup/plugin-commonjs": "~22.0.2", @@ -1849,9 +1830,9 @@ "dev": true }, "node_modules/@types/marked": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@types/marked/-/marked-4.3.0.tgz", - "integrity": "sha512-zK4gSFMjgslsv5Lyvr3O1yCjgmnE4pr8jbG8qVn4QglMwtpvPCf4YT2Wma7Nk95OxUUJI8Z+kzdXohbM7mVpGw==" + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/marked/-/marked-4.3.1.tgz", + "integrity": "sha512-vSSbKZFbNktrQ15v7o1EaH78EbWV+sPQbPjHG+Cp8CaNcPFUEfjZ0Iml/V0bFDwsTlYe8o6XC5Hfdp91cqPV2g==" }, "node_modules/@types/minimist": { "version": "1.2.2", @@ -1860,9 +1841,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.1.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.1.3.tgz", - "integrity": "sha512-NP2yfZpgmf2eDRPmgGq+fjGjSwFgYbihA8/gK+ey23qT9RkxsgNTZvGOEpXgzIGqesTYkElELLgtKoMQTys5vA==" + "version": "20.2.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.2.1.tgz", + "integrity": "sha512-DqJociPbZP1lbZ5SQPk4oag6W7AyaGMO6gSfRwq3PWl4PXTwJpRQJhDq4W0kzrg3w6tJ1SwlvGZ5uKFHY13LIg==" }, "node_modules/@types/normalize-package-data": { "version": "2.4.1", @@ -1918,13 +1899,13 @@ } }, "node_modules/@vitest/expect": { - "version": "0.31.0", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-0.31.0.tgz", - "integrity": "sha512-Jlm8ZTyp6vMY9iz9Ny9a0BHnCG4fqBa8neCF6Pk/c/6vkUk49Ls6UBlgGAU82QnzzoaUs9E/mUhq/eq9uMOv/g==", + "version": "0.31.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-0.31.1.tgz", + "integrity": "sha512-BV1LyNvhnX+eNYzJxlHIGPWZpwJFZaCcOIzp2CNG0P+bbetenTupk6EO0LANm4QFt0TTit+yqx7Rxd1qxi/SQA==", "dev": true, "dependencies": { - "@vitest/spy": "0.31.0", - "@vitest/utils": "0.31.0", + "@vitest/spy": "0.31.1", + "@vitest/utils": "0.31.1", "chai": "^4.3.7" }, "funding": { @@ -1932,12 +1913,12 @@ } }, "node_modules/@vitest/runner": { - "version": "0.31.0", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-0.31.0.tgz", - "integrity": "sha512-H1OE+Ly7JFeBwnpHTrKyCNm/oZgr+16N4qIlzzqSG/YRQDATBYmJb/KUn3GrZaiQQyL7GwpNHVZxSQd6juLCgw==", + "version": "0.31.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-0.31.1.tgz", + "integrity": "sha512-imWuc82ngOtxdCUpXwtEzZIuc1KMr+VlQ3Ondph45VhWoQWit5yvG/fFcldbnCi8DUuFi+NmNx5ehMUw/cGLUw==", "dev": true, "dependencies": { - "@vitest/utils": "0.31.0", + "@vitest/utils": "0.31.1", "concordance": "^5.0.4", "p-limit": "^4.0.0", "pathe": "^1.1.0" @@ -1974,9 +1955,9 @@ } }, "node_modules/@vitest/snapshot": { - "version": "0.31.0", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-0.31.0.tgz", - "integrity": "sha512-5dTXhbHnyUMTMOujZPB0wjFjQ6q5x9c8TvAsSPUNKjp1tVU7i9pbqcKPqntyu2oXtmVxKbuHCqrOd+Ft60r4tg==", + "version": "0.31.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-0.31.1.tgz", + "integrity": "sha512-L3w5uU9bMe6asrNzJ8WZzN+jUTX4KSgCinEJPXyny0o90fG4FPQMV0OWsq7vrCWfQlAilMjDnOF9nP8lidsJ+g==", "dev": true, "dependencies": { "magic-string": "^0.30.0", @@ -2000,9 +1981,9 @@ } }, "node_modules/@vitest/spy": { - "version": "0.31.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-0.31.0.tgz", - "integrity": "sha512-IzCEQ85RN26GqjQNkYahgVLLkULOxOm5H/t364LG0JYb3Apg0PsYCHLBYGA006+SVRMWhQvHlBBCyuByAMFmkg==", + "version": "0.31.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-0.31.1.tgz", + "integrity": "sha512-1cTpt2m9mdo3hRLDyCG2hDQvRrePTDgEJBFQQNz1ydHHZy03EiA6EpFxY+7ODaY7vMRCie+WlFZBZ0/dQWyssQ==", "dev": true, "dependencies": { "tinyspy": "^2.1.0" @@ -2012,9 +1993,9 @@ } }, "node_modules/@vitest/utils": { - "version": "0.31.0", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-0.31.0.tgz", - "integrity": "sha512-kahaRyLX7GS1urekRXN2752X4gIgOGVX4Wo8eDUGUkTWlGpXzf5ZS6N9RUUS+Re3XEE8nVGqNyxkSxF5HXlGhQ==", + "version": "0.31.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-0.31.1.tgz", + "integrity": "sha512-yFyRD5ilwojsZfo3E0BnH72pSVSuLg2356cN1tCEe/0RtDzxTPYwOomIC+eQbot7m6DRy4tPZw+09mB7NkbMmA==", "dev": true, "dependencies": { "concordance": "^5.0.4", @@ -2026,36 +2007,36 @@ } }, "node_modules/@vue/compiler-core": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.3.2.tgz", - "integrity": "sha512-CKZWo1dzsQYTNTft7whzjL0HsrEpMfiK7pjZ2WFE3bC1NA7caUjWioHSK+49y/LK7Bsm4poJZzAMnvZMQ7OTeg==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.3.4.tgz", + "integrity": "sha512-cquyDNvZ6jTbf/+x+AgM2Arrp6G4Dzbb0R64jiG804HRMfRiFXWI6kqUVqZ6ZR0bQhIoQjB4+2bhNtVwndW15g==", "dependencies": { "@babel/parser": "^7.21.3", - "@vue/shared": "3.3.2", + "@vue/shared": "3.3.4", "estree-walker": "^2.0.2", "source-map-js": "^1.0.2" } }, "node_modules/@vue/compiler-dom": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.3.2.tgz", - "integrity": "sha512-6gS3auANuKXLw0XH6QxkWqyPYPunziS2xb6VRenM3JY7gVfZcJvkCBHkb5RuNY1FCbBO3lkIi0CdXUCW1c7SXw==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.3.4.tgz", + "integrity": "sha512-wyM+OjOVpuUukIq6p5+nwHYtj9cFroz9cwkfmP9O1nzH68BenTTv0u7/ndggT8cIQlnBeOo6sUT/gvHcIkLA5w==", "dependencies": { - "@vue/compiler-core": "3.3.2", - "@vue/shared": "3.3.2" + "@vue/compiler-core": "3.3.4", + "@vue/shared": "3.3.4" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.3.2.tgz", - "integrity": "sha512-jG4jQy28H4BqzEKsQqqW65BZgmo3vzdLHTBjF+35RwtDdlFE+Fk1VWJYUnDMMqkFBo6Ye1ltSKVOMPgkzYj7SQ==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.3.4.tgz", + "integrity": "sha512-6y/d8uw+5TkCuzBkgLS0v3lSM3hJDntFEiUORM11pQ/hKvkhSKZrXW6i69UyXlJQisJxuUEJKAWEqWbWsLeNKQ==", "dependencies": { "@babel/parser": "^7.20.15", - "@vue/compiler-core": "3.3.2", - "@vue/compiler-dom": "3.3.2", - "@vue/compiler-ssr": "3.3.2", - "@vue/reactivity-transform": "3.3.2", - "@vue/shared": "3.3.2", + "@vue/compiler-core": "3.3.4", + "@vue/compiler-dom": "3.3.4", + "@vue/compiler-ssr": "3.3.4", + "@vue/reactivity-transform": "3.3.4", + "@vue/shared": "3.3.4", "estree-walker": "^2.0.2", "magic-string": "^0.30.0", "postcss": "^8.1.10", @@ -2074,30 +2055,30 @@ } }, "node_modules/@vue/compiler-ssr": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.3.2.tgz", - "integrity": "sha512-K8OfY5FQtZaSOJHHe8xhEfIfLrefL/Y9frv4k4NsyQL3+0lRKxr9QuJhfdBDjkl7Fhz8CzKh63mULvmOfx3l2w==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.3.4.tgz", + "integrity": "sha512-m0v6oKpup2nMSehwA6Uuu+j+wEwcy7QmwMkVNVfrV9P2qE5KshC6RwOCq8fjGS/Eak/uNb8AaWekfiXxbBB6gQ==", "dependencies": { - "@vue/compiler-dom": "3.3.2", - "@vue/shared": "3.3.2" + "@vue/compiler-dom": "3.3.4", + "@vue/shared": "3.3.4" } }, "node_modules/@vue/reactivity": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.3.2.tgz", - "integrity": "sha512-yX8C4uTgg2Tdj+512EEMnMKbLveoITl7YdQX35AYgx8vBvQGszKiiCN46g4RY6/deeo/5DLbeUUGxCq1qWMf5g==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.3.4.tgz", + "integrity": "sha512-kLTDLwd0B1jG08NBF3R5rqULtv/f8x3rOFByTDz4J53ttIQEDmALqKqXY0J+XQeN0aV2FBxY8nJDf88yvOPAqQ==", "dependencies": { - "@vue/shared": "3.3.2" + "@vue/shared": "3.3.4" } }, "node_modules/@vue/reactivity-transform": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/@vue/reactivity-transform/-/reactivity-transform-3.3.2.tgz", - "integrity": "sha512-iu2WaQvlJHdnONrsyv4ibIEnSsuKF+aHFngGj/y1lwpHQtalpVhKg9wsKMoiKXS9zPNjG9mNKzJS9vudvjzvyg==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@vue/reactivity-transform/-/reactivity-transform-3.3.4.tgz", + "integrity": "sha512-MXgwjako4nu5WFLAjpBnCj/ieqcjE2aJBINUNQzkZQfzIZA4xn+0fV1tIYBJvvva3N3OvKGofRLvQIwEQPpaXw==", "dependencies": { "@babel/parser": "^7.20.15", - "@vue/compiler-core": "3.3.2", - "@vue/shared": "3.3.2", + "@vue/compiler-core": "3.3.4", + "@vue/shared": "3.3.4", "estree-walker": "^2.0.2", "magic-string": "^0.30.0" } @@ -2114,40 +2095,40 @@ } }, "node_modules/@vue/runtime-core": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.3.2.tgz", - "integrity": "sha512-qSl95qj0BvKfcsO+hICqFEoLhJn6++HtsPxmTkkadFbuhe3uQfJ8HmQwvEr7xbxBd2rcJB6XOJg7nWAn/ymC5A==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.3.4.tgz", + "integrity": "sha512-R+bqxMN6pWO7zGI4OMlmvePOdP2c93GsHFM/siJI7O2nxFRzj55pLwkpCedEY+bTMgp5miZ8CxfIZo3S+gFqvA==", "dependencies": { - "@vue/reactivity": "3.3.2", - "@vue/shared": "3.3.2" + "@vue/reactivity": "3.3.4", + "@vue/shared": "3.3.4" } }, "node_modules/@vue/runtime-dom": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.3.2.tgz", - "integrity": "sha512-+drStsJT+0mtgHdarT7cXZReCcTFfm6ptxMrz0kAW5hms6UNBd8Q1pi4JKlncAhu+Ld/TevsSp7pqAZxBBoGng==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.3.4.tgz", + "integrity": "sha512-Aj5bTJ3u5sFsUckRghsNjVTtxZQ1OyMWCr5dZRAPijF/0Vy4xEoRCwLyHXcj4D0UFbJ4lbx3gPTgg06K/GnPnQ==", "dependencies": { - "@vue/runtime-core": "3.3.2", - "@vue/shared": "3.3.2", + "@vue/runtime-core": "3.3.4", + "@vue/shared": "3.3.4", "csstype": "^3.1.1" } }, "node_modules/@vue/server-renderer": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.3.2.tgz", - "integrity": "sha512-QCwh6OGwJg6GDLE0fbQhRTR6tnU+XDJ1iCsTYHXBiezCXAhqMygFRij7BiLF4ytvvHcg5kX9joX5R5vP85++wg==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.3.4.tgz", + "integrity": "sha512-Q6jDDzR23ViIb67v+vM1Dqntu+HUexQcsWKhhQa4ARVzxOY2HbC7QRW/ggkDBd5BU+uM1sV6XOAP0b216o34JQ==", "dependencies": { - "@vue/compiler-ssr": "3.3.2", - "@vue/shared": "3.3.2" + "@vue/compiler-ssr": "3.3.4", + "@vue/shared": "3.3.4" }, "peerDependencies": { - "vue": "3.3.2" + "vue": "3.3.4" } }, "node_modules/@vue/shared": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.3.2.tgz", - "integrity": "sha512-0rFu3h8JbclbnvvKrs7Fe5FNGV9/5X2rPD7KmOzhLSUAiQH5//Hq437Gv0fR5Mev3u/nbtvmLl8XgwCU20/ZfQ==" + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.3.4.tgz", + "integrity": "sha512-7OjdcV8vQ74eiz1TZLzZP4JwqM5fA94K6yntPS5Z25r9HDuGNzaGdgvwKYq6S+MxwF0TFRwe50fIR/MYnakdkQ==" }, "node_modules/@webassemblyjs/ast": { "version": "1.11.6", @@ -2627,9 +2608,9 @@ } }, "node_modules/asciinema-player": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/asciinema-player/-/asciinema-player-3.3.0.tgz", - "integrity": "sha512-4uyCGe83+5gZ06jgIGyV4vl0TS3egBgW0NXyCpDuDDvmzDGHEG8OICChrTecmTvajgLyq8YQet9nI7SYkVt8vQ==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/asciinema-player/-/asciinema-player-3.4.0.tgz", + "integrity": "sha512-dX6jt5S3K6daItsVWzyY9mRDK+ivC2QgqCxFkdSiNslo0vY/ZqA4upcTzqIKZqBtxppovOZk44ltg9VnHG9QVg==", "dependencies": { "@babel/runtime": "^7.21.0", "solid-js": "^1.3.0" @@ -2895,9 +2876,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001486", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001486.tgz", - "integrity": "sha512-uv7/gXuHi10Whlj0pp5q/tsK/32J2QSqVRKQhs2j8VsDCjgyruAh/eEXHF822VqO9yT6iZKw3nRwZRSPBE9OQg==", + "version": "1.0.30001488", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001488.tgz", + "integrity": "sha512-NORIQuuL4xGpIy6iCCQGN4iFjlBXtfKWIenlUuyZJumLRIindLb7wXM+GO8erEhb7vXfcnf4BAg2PrSDN5TNLQ==", "funding": [ { "type": "opencollective", @@ -2995,19 +2976,10 @@ "node": ">=4" } }, - "node_modules/clean-regexp/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/clippie": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/clippie/-/clippie-3.1.4.tgz", - "integrity": "sha512-jrW6sG1zcTEQr5MtCXJzszNmHWV9Fkaco8sAqFeuOApNFP/lRFcUi4JABMmxBJwFZLIvbw2BY3G5E+BjBqZMdQ==" + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clippie/-/clippie-4.0.1.tgz", + "integrity": "sha512-glbCTIRm5gL/vOZ02Dvxa2DXNYrwn8tylBHP3Bz77O1rQ4GMzbzm2Vzv8NYBv1kQYQsHXaUxHBRof8c/f2NXrg==" }, "node_modules/cliui": { "version": "7.0.4", @@ -3224,14 +3196,14 @@ } }, "node_modules/css-loader": { - "version": "6.7.3", - "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.7.3.tgz", - "integrity": "sha512-qhOH1KlBMnZP8FzRO6YCH9UHXQhVMcEGLyNdb7Hv2cpcmJbW0YrddO+tG1ab5nT41KpHIYGsbeHqxB9xPu1pKQ==", + "version": "6.7.4", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.7.4.tgz", + "integrity": "sha512-0Y5uHtK5BswfaGJ+jrO+4pPg1msFBc0pwPIE1VqfpmVn6YbDfYfXMj8rfd7nt+4goAhJueO+H/I40VWJfcP1mQ==", "dependencies": { "icss-utils": "^5.1.0", - "postcss": "^8.4.19", + "postcss": "^8.4.21", "postcss-modules-extract-imports": "^3.0.0", - "postcss-modules-local-by-default": "^4.0.0", + "postcss-modules-local-by-default": "^4.0.1", "postcss-modules-scope": "^3.0.0", "postcss-modules-values": "^4.0.0", "postcss-value-parser": "^4.2.0", @@ -3374,9 +3346,9 @@ "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" }, "node_modules/cytoscape": { - "version": "3.24.0", - "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.24.0.tgz", - "integrity": "sha512-W9fJMrAfr/zKFzDCpRR/wn6uoEQ7gfbJmxPK5DadXj69XyAhZYi1QXLOE+UXJfXVXxqGM1o1eeiIrtxrtB43zA==", + "version": "3.25.0", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.25.0.tgz", + "integrity": "sha512-7MW3Iz57mCUo6JQCho6CmPBCbTlJr7LzyEtIkutG255HLVd4XuBg2I9BkTZLI/e4HoaOB/BiAzXuQybQ95+r9Q==", "dependencies": { "heap": "^0.2.6", "lodash": "^4.17.21" @@ -4144,9 +4116,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.392", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.392.tgz", - "integrity": "sha512-TXQOMW9tnhIms3jGy/lJctLjICOgyueZFJ1KUtm6DTQ+QpxX3p7ZBwB6syuZ9KBuT5S4XX7bgY1ECPgfxKUdOg==" + "version": "1.4.402", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.402.tgz", + "integrity": "sha512-gWYvJSkohOiBE6ecVYXkrDgNaUjo47QEKK0kQzmWyhkH+yoYiG44bwuicTGNSIQRG3WDMsWVZJLRnJnLNkbWvA==" }, "node_modules/elkjs": { "version": "0.8.2", @@ -4336,9 +4308,9 @@ } }, "node_modules/esbuild": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.18.tgz", - "integrity": "sha512-z1lix43jBs6UKjcZVKOw2xx69ffE2aG0PygLL5qJ9OS/gy0Ewd1gW/PUQIOIQGXBHWNywSc0floSKoMFF8aK2w==", + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.19.tgz", + "integrity": "sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==", "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" @@ -4347,28 +4319,28 @@ "node": ">=12" }, "optionalDependencies": { - "@esbuild/android-arm": "0.17.18", - "@esbuild/android-arm64": "0.17.18", - "@esbuild/android-x64": "0.17.18", - "@esbuild/darwin-arm64": "0.17.18", - "@esbuild/darwin-x64": "0.17.18", - "@esbuild/freebsd-arm64": "0.17.18", - "@esbuild/freebsd-x64": "0.17.18", - "@esbuild/linux-arm": "0.17.18", - "@esbuild/linux-arm64": "0.17.18", - "@esbuild/linux-ia32": "0.17.18", - "@esbuild/linux-loong64": "0.17.18", - "@esbuild/linux-mips64el": "0.17.18", - "@esbuild/linux-ppc64": "0.17.18", - "@esbuild/linux-riscv64": "0.17.18", - "@esbuild/linux-s390x": "0.17.18", - "@esbuild/linux-x64": "0.17.18", - "@esbuild/netbsd-x64": "0.17.18", - "@esbuild/openbsd-x64": "0.17.18", - "@esbuild/sunos-x64": "0.17.18", - "@esbuild/win32-arm64": "0.17.18", - "@esbuild/win32-ia32": "0.17.18", - "@esbuild/win32-x64": "0.17.18" + "@esbuild/android-arm": "0.17.19", + "@esbuild/android-arm64": "0.17.19", + "@esbuild/android-x64": "0.17.19", + "@esbuild/darwin-arm64": "0.17.19", + "@esbuild/darwin-x64": "0.17.19", + "@esbuild/freebsd-arm64": "0.17.19", + "@esbuild/freebsd-x64": "0.17.19", + "@esbuild/linux-arm": "0.17.19", + "@esbuild/linux-arm64": "0.17.19", + "@esbuild/linux-ia32": "0.17.19", + "@esbuild/linux-loong64": "0.17.19", + "@esbuild/linux-mips64el": "0.17.19", + "@esbuild/linux-ppc64": "0.17.19", + "@esbuild/linux-riscv64": "0.17.19", + "@esbuild/linux-s390x": "0.17.19", + "@esbuild/linux-x64": "0.17.19", + "@esbuild/netbsd-x64": "0.17.19", + "@esbuild/openbsd-x64": "0.17.19", + "@esbuild/sunos-x64": "0.17.19", + "@esbuild/win32-arm64": "0.17.19", + "@esbuild/win32-ia32": "0.17.19", + "@esbuild/win32-x64": "0.17.19" } }, "node_modules/esbuild-loader": { @@ -4408,15 +4380,12 @@ } }, "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "dev": true, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.8.0" } }, "node_modules/escodegen": { @@ -4807,9 +4776,9 @@ } }, "node_modules/eslint-plugin-vue": { - "version": "9.12.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-9.12.0.tgz", - "integrity": "sha512-xH8PgpDW2WwmFSmRfs/3iWogef1CJzQqX264I65zz77jDuxF2yLy7+GA2diUM8ZNATuSl1+UehMQkb5YEyau5w==", + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-9.13.0.tgz", + "integrity": "sha512-aBz9A8WB4wmpnVv0pYUt86cmH9EkcwWzgEwecBxMoRNhQjTL5i4sqadnwShv/hOdr8Hbl8XANGV7dtX9UQIAyA==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.3.0", @@ -4817,7 +4786,7 @@ "nth-check": "^2.0.1", "postcss-selector-parser": "^6.0.9", "semver": "^7.3.5", - "vue-eslint-parser": "^9.0.1", + "vue-eslint-parser": "^9.3.0", "xml-name-validator": "^4.0.0" }, "engines": { @@ -4894,6 +4863,18 @@ "concat-map": "0.0.1" } }, + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/eslint/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -5010,9 +4991,9 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-diff": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz", - "integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", "dev": true }, "node_modules/fast-glob": { @@ -5285,13 +5266,14 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.0.tgz", - "integrity": "sha512-L049y6nFOuom5wGyRc3/gdTLO94dySVKRACj1RmJZBQXlbTMhtNIgkWkUHq+jYmZvKf14EW1EoJnnjbmoHij0Q==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz", + "integrity": "sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==", "dev": true, "dependencies": { "function-bind": "^1.1.1", "has": "^1.0.3", + "has-proto": "^1.0.1", "has-symbols": "^1.0.3" }, "funding": { @@ -5996,9 +5978,9 @@ } }, "node_modules/is-core-module": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.12.0.tgz", - "integrity": "sha512-RECHCBCd/viahWmwj6enj19sKbHfJrddi/6cBDsNTKbNq0f7VeaUkBo60BqzvPqo/W54ChS62Z5qyun7cfOMqQ==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.12.1.tgz", + "integrity": "sha512-Q4ZuBAe2FUsKtyQJoQHlvP8OvBERxO3jEmy1I7hcRXcJBGGHFh/aJBswbXuS9sgrDH2QUO8ilkwNPHvHMd8clg==", "dependencies": { "has": "^1.0.3" }, @@ -6900,15 +6882,15 @@ } }, "node_modules/markdownlint-cli/node_modules/glob": { - "version": "10.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.2.3.tgz", - "integrity": "sha512-Kb4rfmBVE3eQTAimgmeqc2LwSnN0wIOkkUL6HmxEFxNJ4fHghYHVbFba/HcGcRjE6s9KoMNK3rSOwkL4PioZjg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.2.5.tgz", + "integrity": "sha512-Gj+dFYPZ5hc5dazjXzB0iHg2jKWJZYMjITXYPBRQ/xc2Buw7H0BINknRTwURJ6IC6MEFpYbLvtgVb3qD+DwyuA==", "dev": true, "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^2.0.3", "minimatch": "^9.0.0", - "minipass": "^5.0.0", + "minipass": "^5.0.0 || ^6.0.2", "path-scurry": "^1.7.0" }, "bin": { @@ -7142,9 +7124,9 @@ } }, "node_modules/mini-css-extract-plugin": { - "version": "2.7.5", - "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.7.5.tgz", - "integrity": "sha512-9HaR++0mlgom81s95vvNjxkg52n2b5s//3ZTI1EtzFb98awsLSivs2LMsVqnQ3ay0PVhqWcGNyDaTE961FOcjQ==", + "version": "2.7.6", + "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.7.6.tgz", + "integrity": "sha512-Qk7HcgaPkGG6eD77mLvZS1nmxlao3j+9PkrT9Uc7HAE1id3F41+DdBRYRYkbyfNRGzm8/YWtzhw7nVPmwhqTQw==", "dependencies": { "schema-utils": "^4.0.0" }, @@ -7197,12 +7179,12 @@ } }, "node_modules/minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-6.0.2.tgz", + "integrity": "sha512-MzWSV5nYVT7mVyWCwn2o7JH13w2TBRmmSqSRCKzTw+lmft9X4z+3wjvs06Tzijo5z4W/kahUCDpRXTF+ZrmF/w==", "dev": true, "engines": { - "node": ">=8" + "node": ">=16 || 14 >=14.17" } }, "node_modules/mlly": { @@ -7692,13 +7674,13 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, "node_modules/path-scurry": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.8.0.tgz", - "integrity": "sha512-IjTrKseM404/UAWA8bBbL3Qp6O2wXkanuIE3seCxBH7ctRuvH1QRawy1N3nVDHGkdeZsjOsSe/8AQBL/VQCy2g==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.9.2.tgz", + "integrity": "sha512-qSDLy2aGFPm8i4rsbHd4MNyTcrzHFsLQykrtbuGRknZZCBBVXSv2tSCDN2Cg6Rt/GFRw8GoW9y9Ecw5rIPG1sg==", "dev": true, "dependencies": { "lru-cache": "^9.1.1", - "minipass": "^5.0.0" + "minipass": "^5.0.0 || ^6.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -7907,9 +7889,9 @@ } }, "node_modules/postcss-modules-local-by-default": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.0.tgz", - "integrity": "sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.1.tgz", + "integrity": "sha512-Zr/dB+IlXaEqdoslLHhhqecwj73vc3rDmOpsBNBEVk7P2aqAlz+Ijy0fFbU5Ie9PtreDOIgGa9MsLWakVGl+fA==", "dependencies": { "icss-utils": "^5.0.0", "postcss-selector-parser": "^6.0.2", @@ -7973,9 +7955,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.0.12", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.12.tgz", - "integrity": "sha512-NdxGCAZdRrwVI1sy59+Wzrh+pMMHxapGnpfenDVlMEXoOcvt4pGE0JLK9YY2F5dLxcFYA/YbVQKhcGU+FtSYQg==", + "version": "6.0.13", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz", + "integrity": "sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==", "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -8724,9 +8706,9 @@ } }, "node_modules/semver": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.0.tgz", - "integrity": "sha512-+XC0AD/R7Q2mPSRuy2Id0+CGTZ98+8f+KvwirxOKIEyid+XSx6HbC63p+O4IndTHuX5Z+JxQ0TghCkO5Cg/2HA==", + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.1.tgz", + "integrity": "sha512-Wvss5ivl8TMRZXXESstBA4uR5iXgEN/VC5/sOcuXdVLzcdkz4HWetIoRfG5gb5X+ij/G9rw9YoGn3QoQ8OCSpw==", "dependencies": { "lru-cache": "^6.0.0" }, @@ -9227,9 +9209,9 @@ "dev": true }, "node_modules/stylelint": { - "version": "15.6.1", - "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-15.6.1.tgz", - "integrity": "sha512-d8icFBlVl93Elf3Z5ABQNOCe4nx69is3D/NZhDLAie1eyYnpxfeKe7pCfqzT5W4F8vxHCLSDfV8nKNJzogvV2Q==", + "version": "15.6.2", + "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-15.6.2.tgz", + "integrity": "sha512-fjQWwcdUye4DU+0oIxNGwawIPC5DvG5kdObY5Sg4rc87untze3gC/5g/ikePqVjrAsBUZjwMN+pZsAYbDO6ArQ==", "dev": true, "dependencies": { "@csstools/css-parser-algorithms": "^2.1.1", @@ -9446,9 +9428,9 @@ } }, "node_modules/terser": { - "version": "5.17.3", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.17.3.tgz", - "integrity": "sha512-AudpAZKmZHkG9jueayypz4duuCFJMMNGRMwaPvQKWfxKedh8Z2x3OCoDqIIi1xx5+iwx1u6Au8XQcc9Lke65Yg==", + "version": "5.17.4", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.17.4.tgz", + "integrity": "sha512-jcEKZw6UPrgugz/0Tuk/PVyLAPfMBJf5clnGueo45wTweoV8yh7Q7PEkhkJ5uuUbC7zAxEcG3tqNr1bstkQ8nw==", "dependencies": { "@jridgewell/source-map": "^0.3.2", "acorn": "^8.5.0", @@ -9463,9 +9445,9 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.8", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.8.tgz", - "integrity": "sha512-WiHL3ElchZMsK27P8uIUh4604IgJyAW47LVXGbEoB21DbQcZ+OuMpGjVYnEUaqcWM6dO8uS2qUbA7LSCWqvsbg==", + "version": "5.3.9", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.9.tgz", + "integrity": "sha512-ZuXsqE07EcggTWQjXUj+Aot/OMcD0bMKGgF63f7UxYcu5/AJF53aIpK1YoP5xR9l6s/Hy2b+t1AM0bLNPRuhwA==", "dependencies": { "@jridgewell/trace-mapping": "^0.3.17", "jest-worker": "^27.4.5", @@ -9703,9 +9685,9 @@ } }, "node_modules/tslib": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz", - "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.2.tgz", + "integrity": "sha512-5svOrSA2w3iGFDs1HibEVBGbDrAY82bFQ3HZ3ixB+88nsbsWQoKqDRb5UBYAUPEzbBn6dAp5gRNXglySbx1MlA==", "dev": true }, "node_modules/type-check": { @@ -9923,9 +9905,9 @@ } }, "node_modules/vite": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-4.3.5.tgz", - "integrity": "sha512-0gEnL9wiRFxgz40o/i/eTBwm+NEbpUeTWhzKrZDSdKm6nplj+z4lKz8ANDgildxHm47Vg8EUia0aicKbawUVVA==", + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.3.8.tgz", + "integrity": "sha512-uYB8PwN7hbMrf4j1xzGDk/lqjsZvCDbt/JC5dyfxc19Pg8kRm14LinK/uq+HSLNswZEoKmweGdtpbnxRtrAXiQ==", "dev": true, "dependencies": { "esbuild": "^0.17.5", @@ -9971,9 +9953,9 @@ } }, "node_modules/vite-node": { - "version": "0.31.0", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-0.31.0.tgz", - "integrity": "sha512-8x1x1LNuPvE2vIvkSB7c1mApX5oqlgsxzHQesYF7l5n1gKrEmrClIiZuOFbFDQcjLsmcWSwwmrWrcGWm9Fxc/g==", + "version": "0.31.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-0.31.1.tgz", + "integrity": "sha512-BajE/IsNQ6JyizPzu9zRgHrBwczkAs0erQf/JRpgTIESpKvNj9/Gd0vxX905klLkb0I0SJVCKbdrl5c6FnqYKA==", "dev": true, "dependencies": { "cac": "^6.7.14", @@ -9994,9 +9976,9 @@ } }, "node_modules/vite/node_modules/rollup": { - "version": "3.21.6", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.21.6.tgz", - "integrity": "sha512-SXIICxvxQxR3D4dp/3LDHZIJPC8a4anKMHd4E3Jiz2/JnY+2bEjqrOokAauc5ShGVNFHlEFjBXAXlaxkJqIqSg==", + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.22.0.tgz", + "integrity": "sha512-imsigcWor5Y/dC0rz2q0bBt9PabcL3TORry2hAa6O6BuMvY71bqHyfReAz5qyAqiQATD1m70qdntqBfBQjVWpQ==", "dev": true, "bin": { "rollup": "dist/bin/rollup" @@ -10010,19 +9992,19 @@ } }, "node_modules/vitest": { - "version": "0.31.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-0.31.0.tgz", - "integrity": "sha512-JwWJS9p3GU9GxkG7eBSmr4Q4x4bvVBSswaCFf1PBNHiPx00obfhHRJfgHcnI0ffn+NMlIh9QGvG75FlaIBdKGA==", + "version": "0.31.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-0.31.1.tgz", + "integrity": "sha512-/dOoOgzoFk/5pTvg1E65WVaobknWREN15+HF+0ucudo3dDG/vCZoXTQrjIfEaWvQXmqScwkRodrTbM/ScMpRcQ==", "dev": true, "dependencies": { - "@types/chai": "^4.3.4", + "@types/chai": "^4.3.5", "@types/chai-subset": "^1.3.3", "@types/node": "*", - "@vitest/expect": "0.31.0", - "@vitest/runner": "0.31.0", - "@vitest/snapshot": "0.31.0", - "@vitest/spy": "0.31.0", - "@vitest/utils": "0.31.0", + "@vitest/expect": "0.31.1", + "@vitest/runner": "0.31.1", + "@vitest/snapshot": "0.31.1", + "@vitest/spy": "0.31.1", + "@vitest/utils": "0.31.1", "acorn": "^8.8.2", "acorn-walk": "^8.2.0", "cac": "^6.7.14", @@ -10035,10 +10017,10 @@ "picocolors": "^1.0.0", "std-env": "^3.3.2", "strip-literal": "^1.0.1", - "tinybench": "^2.4.0", + "tinybench": "^2.5.0", "tinypool": "^0.5.0", "vite": "^3.0.0 || ^4.0.0", - "vite-node": "0.31.0", + "vite-node": "0.31.1", "why-is-node-running": "^2.2.2" }, "bin": { @@ -10100,9 +10082,9 @@ } }, "node_modules/vm2": { - "version": "3.9.18", - "resolved": "https://registry.npmjs.org/vm2/-/vm2-3.9.18.tgz", - "integrity": "sha512-iM7PchOElv6Uv6Q+0Hq7dcgDtWWT6SizYqVcvol+1WQc+E9HlgTCnPozbQNSP3yDV9oXHQOEQu530w2q/BCVZg==", + "version": "3.9.19", + "resolved": "https://registry.npmjs.org/vm2/-/vm2-3.9.19.tgz", + "integrity": "sha512-J637XF0DHDMV57R6JyVsTak7nIL8gy5KH4r1HiwWLf/4GBbb5MKL5y7LpmF4A8E2nR6XmzpmMFQ7V7ppPTmUQg==", "dev": true, "dependencies": { "acorn": "^8.7.0", @@ -10116,15 +10098,15 @@ } }, "node_modules/vue": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.3.2.tgz", - "integrity": "sha512-98hJcAhyDwZoOo2flAQBSPVYG/o0HA9ivIy2ktHshjE+6/q8IMQ+kvDKQzOZTFPxvnNMcGM+zS2A00xeZMA7tA==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.3.4.tgz", + "integrity": "sha512-VTyEYn3yvIeY1Py0WaYGZsXnz3y5UnGi62GjVEqvEGPl6nxbOrCXbVOTQWBEJUqAyTUk2uJ5JLVnYJ6ZzGbrSw==", "dependencies": { - "@vue/compiler-dom": "3.3.2", - "@vue/compiler-sfc": "3.3.2", - "@vue/runtime-dom": "3.3.2", - "@vue/server-renderer": "3.3.2", - "@vue/shared": "3.3.2" + "@vue/compiler-dom": "3.3.4", + "@vue/compiler-sfc": "3.3.4", + "@vue/runtime-dom": "3.3.4", + "@vue/server-renderer": "3.3.4", + "@vue/shared": "3.3.4" } }, "node_modules/vue-bar-graph": { @@ -10137,9 +10119,9 @@ } }, "node_modules/vue-eslint-parser": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.2.1.tgz", - "integrity": "sha512-tPOex4n6jit4E7h68auOEbDMwE58XiP4dylfaVTCOVCouR45g+QFDBjgIdEU52EXJxKyjgh91dLfN2rxUcV0bQ==", + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.3.0.tgz", + "integrity": "sha512-48IxT9d0+wArT1+3wNIy0tascRoywqSUe2E1YalIC1L8jsUGe5aJQItWfRok7DVFGz3UYvzEI7n5wiTXsCMAcQ==", "dev": true, "dependencies": { "debug": "^4.3.4", @@ -10232,9 +10214,9 @@ } }, "node_modules/webpack": { - "version": "5.82.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.82.1.tgz", - "integrity": "sha512-C6uiGQJ+Gt4RyHXXYt+v9f+SN1v83x68URwgxNQ98cvH8kxiuywWGP4XeNZ1paOzZ63aY3cTciCEQJNFUljlLw==", + "version": "5.83.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.83.1.tgz", + "integrity": "sha512-TNsG9jDScbNuB+Lb/3+vYolPplCS3bbEaJf+Bj0Gw4DhP3ioAflBb1flcRt9zsWITyvOhM96wMQNRWlSX52DgA==", "dependencies": { "@types/eslint-scope": "^3.7.3", "@types/estree": "^1.0.0", diff --git a/package.json b/package.json index 179f0beb2d633..e33e013cf846f 100644 --- a/package.json +++ b/package.json @@ -17,13 +17,12 @@ "@github/text-expander-element": "2.3.0", "@mcaptcha/vanilla-glue": "0.1.0-alpha-3", "@primer/octicons": "19.1.0", - "@vue/compiler-sfc": "3.3.2", "@webcomponents/custom-elements": "1.6.0", "add-asset-webpack-plugin": "2.0.1", "ansi-to-html": "0.7.2", - "asciinema-player": "3.3.0", - "clippie": "3.1.4", - "css-loader": "6.7.3", + "asciinema-player": "3.4.0", + "clippie": "4.0.1", + "css-loader": "6.7.4", "dropzone": "6.0.0-beta.2", "easymde": "2.18.0", "esbuild-loader": "3.0.1", @@ -34,7 +33,7 @@ "katex": "0.16.7", "license-checker-webpack-plugin": "0.2.1", "mermaid": "10.1.0", - "mini-css-extract-plugin": "2.7.5", + "mini-css-extract-plugin": "2.7.6", "minimatch": "9.0.0", "monaco-editor": "0.38.0", "monaco-editor-webpack-plugin": "7.0.1", @@ -45,11 +44,11 @@ "tippy.js": "6.3.7", "tributejs": "5.1.3", "uint8-to-base64": "0.2.0", - "vue": "3.3.2", + "vue": "3.3.4", "vue-bar-graph": "2.0.0", "vue-loader": "17.1.1", "vue3-calendar-heatmap": "2.0.5", - "webpack": "5.82.1", + "webpack": "5.83.1", "webpack-cli": "5.1.1", "workbox-routing": "6.5.4", "workbox-strategies": "6.5.4", @@ -71,15 +70,15 @@ "eslint-plugin-regexp": "1.15.0", "eslint-plugin-sonarjs": "0.19.0", "eslint-plugin-unicorn": "47.0.0", - "eslint-plugin-vue": "9.12.0", + "eslint-plugin-vue": "9.13.0", "eslint-plugin-wc": "1.5.0", "jsdom": "22.0.0", "markdownlint-cli": "0.34.0", - "stylelint": "15.6.1", + "stylelint": "15.6.2", "stylelint-declaration-strict-value": "1.9.2", "svgo": "3.0.2", "updates": "14.1.0", - "vitest": "0.31.0" + "vitest": "0.31.1" }, "browserslist": [ "defaults", From 1698c15cba8f9eedfcb6af8226f3e97950480aca Mon Sep 17 00:00:00 2001 From: GiteaBot Date: Sat, 20 May 2023 00:22:02 +0000 Subject: [PATCH 11/27] [skip ci] Updated translations via Crowdin --- options/locale/locale_fr-FR.ini | 408 +++++++++++++++++++++++++++++++- options/locale/locale_tr-TR.ini | 7 + 2 files changed, 408 insertions(+), 7 deletions(-) diff --git a/options/locale/locale_fr-FR.ini b/options/locale/locale_fr-FR.ini index ef18cb2af7075..d7351acb190c8 100644 --- a/options/locale/locale_fr-FR.ini +++ b/options/locale/locale_fr-FR.ini @@ -318,6 +318,7 @@ repo_no_results=Aucun dépôt correspondant n'a été trouvé. user_no_results=Aucun utilisateur correspondant n'a été trouvé. org_no_results=Aucune organisation correspondante n'a été trouvée. code_no_results=Aucun code source correspondant à votre terme de recherche n'a été trouvé. +code_search_results=Résultats de la recherche pour « %s » code_last_indexed_at=Dernière indexation %s relevant_repositories_tooltip=Les dépôts qui sont des forks ou qui n'ont aucun sujet, aucune icône et aucune description sont cachés. relevant_repositories=Seuls les dépôts pertinents sont affichés,
    afficher les résultats non filtrés. @@ -394,6 +395,7 @@ password_pwned_err=Impossible d'envoyer la demande à HaveIBeenPwned [mail] view_it_on=Voir sur %s +reply=ou répondez directement à cet e-mail link_not_working_do_paste=Le lien ne fonctionne pas ? Essayez de le copier-coller dans votre navigateur. hi_user_x=Bonjour %s, @@ -495,6 +497,7 @@ size_error=` doit être à la taille de %s.` min_size_error=` %s caractères minimum ` max_size_error=` %s caractères maximum ` email_error=` adresse e-mail invalide ` +url_error=`"%s" n'est pas une URL valide.` include_error=` doit contenir la sous-chaîne "%s".` glob_pattern_error=` le motif de développement est invalide : %s.` regex_pattern_error=` le motif regex est invalide : %s.` @@ -540,8 +543,10 @@ organization_leave_success=Vous avez quitté l'organisation %s avec succès. invalid_ssh_key=Impossible de vérifier votre clé SSH : %s invalid_gpg_key=Impossible de vérifier votre clé GPG : %s invalid_ssh_principal=Principal invalide : %s +must_use_public_key=La clé que vous avez fournie est une clé privée. Veuillez ne pas divulguer votre clé privée. Utilisez votre clé publique à la place. auth_failed=Échec d'authentification : %v +still_own_repo=Votre compte possède toujours un ou plusieurs dépôts, vous devez d’abord les supprimer ou les transférer. still_has_org=Votre compte est un membre d’une ou plusieurs organisations, veuillez d’abord les quitter. still_own_packages=Votre compte possède toujours un ou plusieurs paquets, vous devez d’abord les supprimer ou les transférer. org_still_own_repo=Cette organisation possède encore un ou plusieurs dépôts. Vous devez d’abord les supprimer ou les transférer. @@ -569,6 +574,7 @@ email_visibility.limited=Votre adresse e-mail est visible pour tous les utilisat email_visibility.private=Votre adresse e-mail n'est visible que pour vous et les administrateurs form.name_reserved=Le nom d’utilisateur "%s" est réservé. +form.name_pattern_not_allowed=Le motif "%s" n'est pas autorisé dans un nom de d'utilisateur. form.name_chars_not_allowed=Le nom d'utilisateur "%s" contient des caractères non valides. [settings] @@ -578,6 +584,7 @@ appearance=Apparence password=Mot de passe security=Sécurité avatar=Avatar +ssh_gpg_keys=Clés SSH / GPG social=Réseaux Sociaux applications=Applications orgs=Gérer les organisations @@ -586,10 +593,13 @@ delete=Supprimer le compte twofa=Authentification à deux facteurs account_link=Comptes liés organization=Organisations +uid=Uid +webauthn=Clés de sécurité public_profile=Profil public biography_placeholder=Parlez-nous un peu de vous. profile_desc=Votre adresse e-mail sera utilisée pour les notifications et d'autres opérations. +password_username_disabled=Les utilisateurs externes ne sont pas autorisés à modifier leur nom d'utilisateur. Veuillez contacter l'administrateur de votre site pour plus de détails. full_name=Non Complet website=Site Web location=Localisation @@ -604,12 +614,19 @@ cancel=Annuler language=Langue ui=Thème hidden_comment_types=Types de commentaires masqués +hidden_comment_types.issue_ref_tooltip=Commentaires où l’utilisateur change la branche/étiquette associée au ticket comment_type_group_reference=Référence +comment_type_group_label=Libellé comment_type_group_milestone=Jalon comment_type_group_assignee=Assigné à comment_type_group_title=Titre comment_type_group_branch=Branche +comment_type_group_time_tracking=Minuteur +comment_type_group_deadline=Échéance comment_type_group_dependency=Dépendance +comment_type_group_lock=Verrouiller le statut +comment_type_group_review_request=Demande de revue +comment_type_group_pull_request_push=Révisions ajoutées comment_type_group_project=Projet comment_type_group_issue_ref=Référence du ticket saved_successfully=Vos paramètres ont été enregistrés avec succès. @@ -618,38 +635,123 @@ keep_activity_private=Masquer l'activité de la page de profil keep_activity_private_popup=Rend l'activité visible uniquement pour vous et les administrateurs lookup_avatar_by_mail=Rechercher un avatar par adresse e-mail +federated_avatar_lookup=Recherche d'avatars fédérés enable_custom_avatar=Utiliser un avatar personnalisé choose_new_avatar=Sélectionner un nouvel avatar delete_current_avatar=Supprimer l'avatar actuel uploaded_avatar_not_a_image=Le fichier téléchargé n'est pas une image. +uploaded_avatar_is_too_big=Le fichier téléchargé dépasse la taille limite. update_avatar_success=Votre avatar a été mis à jour. update_user_avatar_success=L'avatar de l'utilisateur a été mis à jour. +old_password=Mot de passe actuel +new_password=Nouveau mot de passe +retype_new_password=Retapez le nouveau mot de passe +password_incorrect=Le mot de passe actuel est incorrect. +change_password_success=Votre mot de passe a été mis à jour. Désormais, connectez-vous avec votre nouveau mot de passe. password_change_disabled=Les mots de passes des comptes utilisateurs externes ne peuvent pas être modifiées depuis l'interface web Gitea. +emails=Adresses e-mail +manage_emails=Gérer les adresses e-mail +manage_themes=Sélectionner le thème par défaut +manage_openid=Gérer les adresses OpenID +email_desc=Votre adresse e-mail principale sera utilisée pour les notifications et d'autres opérations. +theme_desc=Ce sera votre thème par défaut sur le site. primary=Principale activated=Activé +requires_activation=Nécessite une activation +primary_email=Faire de cette adresse votre adresse principale delete_email=Exclure openid_deletion_success=L'adresse OpenID a été supprimée. add_new_openid=Ajouter une nouvelle URI OpenID - +add_openid=Ajouter une URI OpenID +email_preference_set_success=L'e-mail de préférence a été défini avec succès. +add_openid_success=La nouvelle adresse OpenID a été ajoutée. +keep_email_private=Cacher l'adresse e-mail +keep_email_private_popup=Votre adresse e-mail ne sera visible que de vous et des administrateurs +openid_desc=OpenID vous permet de confier l'authentification à une tierce partie. + +manage_ssh_keys=Gérer les clés SSH +manage_ssh_principals=Gérer les certificats principaux SSH +manage_gpg_keys=Gérer les clés GPG +add_key=Ajouter une clé +ssh_desc=Ces clefs SSH publiques sont associées à votre compte. Les clefs privées correspondantes permettent l'accès complet à vos repos. +principal_desc=Ces Principaux de certificats SSH sont associés à votre compte et permettent un accès complet à vos dépôts. +gpg_desc=Ces clefs GPG sont associées avec votre compte. Conservez-les en lieu sûr, car elles permettent la vérification de vos commits. +ssh_helper=Besoin d'aide ? Consultez le guide de GitHub pour créer vos propres clés SSH ou résoudre les problèmes courants que vous pourriez rencontrer en utilisant SSH. +gpg_helper=Besoin d'aide ? Consultez le guide de GitHub sur GPG. +add_new_key=Ajouter une clé SSH +add_new_gpg_key=Ajouter une clé GPG +key_content_ssh_placeholder=Commence par 'ssh-ed25519', 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', 'sk-ecdsa-sha2-nistp256@openssh.com' ou par 'sk-ssh-ed25519@openssh.com' +key_content_gpg_placeholder=Commence par '-----BEGIN PGP PUBLIC KEY BLOCK-----' +add_new_principal=Ajouter le principal +ssh_key_been_used=Cette clé SSH a déjà été ajoutée au serveur. +ssh_key_name_used=Une clé SSH avec le même nom existe déjà sur votre compte. +ssh_principal_been_used=Ce principal a déjà été ajouté au serveur. +gpg_key_id_used=Une clé publique GPG avec le même ID existe déjà. +gpg_no_key_email_found=Cette clé GPG ne correspond à aucune adresse e-mail activée associée à votre compte. Elle peut toujours être ajoutée si vous signez le jeton fourni. +gpg_key_matched_identities=Identités correspondantes : +gpg_key_matched_identities_long=Les identités intégrées dans cette clé correspondent aux adresses e-mail activées suivantes pour cet utilisateur. Les commits correspondant à ces adresses e-mail peuvent être vérifiés avec cette clé. +gpg_key_verified=Clé vérifiée +gpg_key_verified_long=La clé a été vérifiée avec un jeton et peut être utilisée pour vérifier les révisions correspondant à toutes les adresses e-mails pour cet utilisateur en plus de toutes les identités pour cette clé. gpg_key_verify=Vérifier +gpg_invalid_token_signature=La clé GPG, la signature et le jeton fournis ne correspondent pas ou le jeton n'est pas à jour. +gpg_token_required=Vous devez fournir une signature pour le jeton ci-dessous gpg_token=Jeton +gpg_token_help=Vous pouvez générer une signature en utilisant : +gpg_token_signature=Signature GPG renforcée +key_signature_gpg_placeholder=Commence par '-----BEGIN PGP SIGNATURE-----' +verify_gpg_key_success=La clé GPG "%s" a été vérifiée. +ssh_key_verified=Clé vérifiée +ssh_key_verified_long=La clé a été vérifiée avec un jeton et peut être utilisée pour vérifier les commits correspondant à toutes les adresses mail activées pour cet utilisateur. ssh_key_verify=Vérifier +ssh_invalid_token_signature=La clé SSH, la signature ou le jeton fournis ne correspondent pas ou le jeton est périmé. +ssh_token_required=Vous devez fournir une signature pour le jeton ci-dessous ssh_token=Jeton +ssh_token_help=Vous pouvez générer une signature en utilisant : +ssh_token_signature=Signature SSH renforcée +key_signature_ssh_placeholder=Commence par '-----BEGIN SSH SIGNATURE-----' +verify_ssh_key_success=La clé SSH "%s" a été vérifiée. +subkeys=Sous-clés +key_id=ID de la clé +key_name=Nom de la Clé key_content=Contenu principal_content=Contenu delete_key=Exclure +gpg_key_deletion_desc=Supprimer une clé GPG renie les révisions signées par celle-ci. Continuer ? can_read_info=Lue(s) +can_write_info=Écriture delete_token=Supprimer access_token_deletion_cancel_action=Annuler access_token_deletion_confirm_action=Supprimer - +delete_token_success=Ce jeton a été supprimé. Les applications l'utilisant n'ont plus accès à votre compte. +select_scopes=Sélectionner les périmètres +scopes_list=Périmètres : + +manage_oauth2_applications=Gérer les applications OAuth2 +edit_oauth2_application=Modifier l'application OAuth2 +oauth2_applications_desc=Les applications OAuth2 permettent à votre application tierce d'authentifier en toute sécurité les utilisateurs de cette instance Gitea. +remove_oauth2_application=Supprimer l'application OAuth2 +remove_oauth2_application_desc=La suppression d'une application OAuth2 révoquera l'accès à tous les jetons d'accès signés. Continuer ? +remove_oauth2_application_success=L'application a été supprimée. +create_oauth2_application=Créer une nouvelle application OAuth2 +create_oauth2_application_button=Créer une application +create_oauth2_application_success=Vous avez créé une nouvelle application OAuth2 avec succès. +update_oauth2_application_success=Vous avez mis à jour l'application OAuth2 avec succès. +oauth2_application_name=Nom de l'Application +oauth2_redirect_uri=URI de redirection save_application=Enregistrer +oauth2_client_id=ID du client oauth2_client_secret=Secret du client +oauth2_regenerate_secret=Regénérer le secret +oauth2_regenerate_secret_hint=Avez-vous perdu votre secret ? +oauth2_client_secret_hint=Le secret ne sera plus visible si vous revenez sur cette page. Veuillez sauvegarder votre secret. oauth2_application_edit=Éditer +oauth2_application_create_description=Les applications OAuth2 permettent à votre application tierce d'accéder aux comptes d'utilisateurs de cette instance. +oauth2_application_remove_description=La suppression d'une application OAuth2 l'empêchera d'accéder aux comptes d'utilisateurs autorisés sur cette instance. Poursuivre ? +authorized_oauth2_applications=Applications OAuth2 autorisées @@ -661,6 +763,7 @@ confirm_delete_account=Confirmez la suppression delete_account_title=Supprimer cet utilisateur email_notifications.enable=Activer les notifications par e-mail +email_notifications.disable=Désactiver les notifications par e-mail visibility.public=Public visibility.limited=Limité @@ -864,6 +967,7 @@ issues=Tickets pulls=Demandes d'ajout project_board=Projets packages=Paquets +actions=Actions labels=Étiquettes org_labels_desc=Les étiquettes de niveau d'une organisation peuvent être utilisés avec tous les dépôts de cette organisation org_labels_desc_manage=gérer @@ -937,6 +1041,7 @@ editor.commit_directly_to_this_branch=Soumettre directement dans la branche nouvelle branche pour cette révision et envoyer une nouvelle demande d'ajout. editor.create_new_branch_np=Créer une nouvelle branche pour cette révision. editor.propose_file_change=Proposer une modification du fichier +editor.new_branch_name=Nommer la nouvelle branche pour cette révision editor.new_branch_name_desc=Nouveau nom de la branche… editor.cancel=Annuler editor.filename_cannot_be_empty=Le nom de fichier ne peut être vide. @@ -959,6 +1064,7 @@ commits.desc=Naviguer dans l'historique des modifications. commits.commits=Révisions commits.nothing_to_compare=Ces branches sont égales. commits.search=Rechercher des révisions… +commits.search.tooltip=Vous pouvez préfixer les mots-clés avec "author:", "committer:", "after:", ou "before:", par exemple "revert author:Alice before:2019-01-13". commits.find=Chercher commits.search_all=Toutes les branches commits.author=Auteur @@ -1053,6 +1159,7 @@ issues.label_templates.title=Charger un ensemble prédéfini d'étiquettes issues.label_templates.info=Il n'existe pas encore d'étiquettes. Créez une étiquette avec 'Nouvelle étiquette' ou utilisez un jeu d'étiquettes prédéfini : issues.label_templates.helper=Sélectionnez un ensemble d'étiquettes issues.label_templates.use=Utiliser le jeu de labels +issues.label_templates.fail_to_load_file=Impossible de charger le fichier de modèle de libellé "%s" : %v issues.add_label=a ajouté l'étiquette %s %s issues.add_labels=a ajouté les étiquettes %s %s issues.remove_label=a supprimé l'étiquette %s %s @@ -1157,6 +1264,8 @@ issues.save=Enregistrer issues.label_title=Nom de l'étiquette issues.label_description=Description de l’étiquette issues.label_color=Couleur de l'étiquette +issues.label_exclusive_desc=Nommez le libellé périmètre/élément pour qu'il soit mutuellement exclusif avec d'autres libellés du périmètre. +issues.label_exclusive_warning=Tout libellé conflictuel sera supprimé lors de l'édition des libellés d'un ticket ou d'une demande de tirage. issues.label_count=%d étiquettes issues.label_open_issues=%d tickets ouverts issues.label_edit=Éditer @@ -1326,6 +1435,7 @@ pulls.remove_prefix=Enlever le préfixe %s pulls.data_broken=Cette demande de fusion est impossible par manque d'informations de bifurcation. pulls.files_conflicted=Cette demande d'ajout contient des modifications en conflit avec la branche ciblée. pulls.is_checking=Vérification des conflits de fusion en cours. Réessayez dans quelques instants. +pulls.is_empty=Les changements sur cette branche sont déjà sur la branche cible. Cette révision sera vide. pulls.required_status_check_failed=Certains contrôles requis n'ont pas réussi. pulls.required_status_check_missing=Certains contrôles requis sont manquants. pulls.required_status_check_administrator=En tant qu'administrateur, vous pouvez toujours fusionner cette requête de pull. @@ -1552,6 +1662,7 @@ settings.hooks=Webhooks settings.githooks=Déclencheurs Git settings.basic_settings=Paramètres de base settings.mirror_settings=Réglages Miroir +settings.mirror_settings.docs=Configurez votre projet pour automatiquement pousser ou tirer les modifications d'un autre dépôt. Les branches, étiquettes et révisions seront automatiquement synchronisées. Comment mettre en miroir les dépôts ? settings.mirror_settings.mirrored_repository=Dépôt en miroir settings.mirror_settings.direction=Direction settings.mirror_settings.direction.push=Pousser @@ -1737,6 +1848,7 @@ settings.event_pull_request_review_desc=Demande d'ajout approvée, rejetée ou c settings.event_pull_request_sync=Demande d'ajout synchronisée settings.event_pull_request_sync_desc=Demande d'ajout synchronisée. settings.branch_filter=Filtre de branche +settings.branch_filter_desc=Liste blanche pour les poussées, la création et la suppression de branches, spécifiées par motif de glob. Si vide ou *, les événements pour toutes les branches sont signalés. Voir la documentation github.com/gobwas/glob pour la syntaxe. Exemples: master, {master,release*}. settings.active=Actif settings.active_helper=Les informations sur les événements déclenchés seront envoyées à cette url de Webhook. settings.add_hook_success=Nouveau Webhook ajouté. @@ -1813,7 +1925,12 @@ settings.choose_branch=Choisissez une branche… settings.no_protected_branch=Il n'y a pas de branche protégée. settings.edit_protected_branch=Éditer settings.protected_branch_required_approvals_min=Le nombre de revues nécessaires ne peut être négatif. -settings.tags=Tags +settings.tags=Étiquettes +settings.tags.protection=Protection d'étiquette +settings.tags.protection.pattern=Motif d'étiquette +settings.tags.protection.create=Protéger l'étiquette +settings.tags.protection.none=Il n'y a pas d'étiquettes protégées. +settings.tags.protection.pattern.description=Vous pouvez utiliser soit un nom unique, soit un motif de glob ou une expression régulière qui correspondront à plusieurs étiquettes. Pour plus d'informations, veuillez vous reporter au guide sur les étiquettes protégées. settings.bot_token=Jeton de Bot settings.chat_id=ID de conversation settings.matrix.homeserver_url=URL du serveur d'accueil @@ -1826,6 +1943,7 @@ settings.archive.success=Ce dépôt a été archivé avec succès. settings.archive.error=Une erreur s'est produite lors de l'archivage du dépôt. Voir le journal pour plus de détails. settings.archive.error_ismirror=Vous ne pouvez pas archiver un dépôt en miroir. settings.archive.branchsettings_unavailable=Le paramétrage des branches n'est pas disponible quand le dépôt est archivé. +settings.archive.tagsettings_unavailable=Le paramétrage des étiquettes n'est pas disponible si le dépôt est archivé. settings.unarchive.button=Désarchiver ce dépôt settings.unarchive.header=Désarchiver ce dépôt settings.unarchive.text=Désarchiver le dépôt lui permettra de recevoir des révisions, ainsi que des nouveaux tickets ou demandes d'ajout. @@ -1886,6 +2004,7 @@ diff.file_image_height=Hauteur diff.file_byte_size=Taille diff.file_suppressed=Fichier diff supprimé car celui-ci est trop grand diff.file_suppressed_line_too_long=Diff de fichier supprimé car une ou plusieurs lignes sont trop longues +diff.generated=générée diff.comment.placeholder=Laisser un commentaire diff.comment.markdown_info=Mise en page avec markdown est prise en charge. diff.comment.add_single_comment=Ajouter un commentaire @@ -1914,13 +2033,17 @@ release.prerelease=Pré-publication release.stable=Stable release.compare=Comparer release.edit=Éditer +release.ahead.commits=%d révisions release.ahead.target=à %s depuis cette livraison +tag.ahead.target=à %s depuis cette étiquette release.source_code=Code source release.new_subheader=Les versions organisent les versions publiées du projet. release.edit_subheader=Les versions organisent les versions publiées du projet. release.tag_name=Nom du tag release.target=Cible release.tag_helper=Choisissez une étiquette existante ou créez une nouvelle étiquette. +release.tag_helper_new=Nouvelle étiquette. Cette étiquette sera créée à partir de la cible. +release.tag_helper_existing=Étiquette existante. release.prerelease_desc=Marquer comme pré-version release.prerelease_helper=Marquer cette version comme impropre à la production. release.cancel=Annuler @@ -1930,13 +2053,19 @@ release.edit_release=Modifier la version release.delete_release=Supprimer cette version release.delete_tag=Supprimer l'étiquette release.deletion=Supprimer cette version +release.deletion_desc=La suppression d'une version la supprime seulement de Gitea. Les étiquettes Git, le contenu du dépôt et l'historique restent inchangés. Voulez vous continuer ? release.deletion_success=Cette livraison a été supprimée. +release.deletion_tag_desc=Ceci supprimera cette étiquette du dépôt. Le contenu du dépôt et l'historique resteront inchangés. Continuer ? release.deletion_tag_success=L'étiquette a été supprimée. release.tag_name_already_exist=Une version avec ce nom d'étiquette existe déjà. release.tag_name_invalid=Le nom de l'étiquette est invalide. +release.tag_name_protected=Ce nom d'étiquette est protégé. release.tag_already_exist=Ce nom d'étiquette existe déjà. release.downloads=Téléchargements release.download_count=Télécharger: %s +release.add_tag_msg=Utiliser le titre et le contenu de la version comme message d'étiquette. +release.add_tag=Créer uniquement une Balise +release.tags_for=Étiquettes pour %s branch.name=Nom de la branche branch.search=Rechercher des branches @@ -1944,22 +2073,38 @@ branch.delete_head=Supprimer branch.delete_html=Supprimer la branche branch.delete_desc=Supprimer une branche est permanent. Cela NE PEUVENT être annulées. Continuer ? branch.create_branch=Créer la branche %s +branch.create_from=`de "%s"` +branch.tag_collision=La branche "%s" ne peut être créée car une étiquette avec un nom identique existe déjà dans le dépôt. branch.deleted_by=Supprimée par %s +branch.restore=`Restaurer la branche "%s"` +branch.download=`Télécharger la branche "%s"` branch.included_desc=Cette branche fait partie de la branche par défaut branch.included=Incluses branch.create_new_branch=Créer une branche à partir de la branche : branch.confirm_create_branch=Créer une branche branch.create_branch_operation=Créer une branche branch.new_branch=Créer une nouvelle branche +branch.new_branch_from=`Créer une nouvelle branche à partir de "%s"` +branch.renamed=La branche %s à été renommée en %s. +tag.create_tag=Créer l'étiquette %s +tag.create_tag_operation=Créer une étiquette +tag.confirm_create_tag=Créer une étiquette +tag.create_tag_from=`Créer une nouvelle étiquette à partir de "%s"` +tag.create_success=L'étiquette "%s" a été créée. topic.manage_topics=Gérer les sujets topic.done=Terminé topic.count_prompt=Vous ne pouvez pas sélectionner plus de 25 sujets topic.format_prompt=Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères. +find_file.go_to_file=Aller au fichier +find_file.no_matching=Aucun fichier correspondant trouvé +error.csv.too_large=Impossible de visualiser le fichier car il est trop volumineux. +error.csv.unexpected=Impossible de visualiser ce fichier car il contient un caractère inattendu ligne %d, colonne %d. +error.csv.invalid_field_count=Impossible de visualiser ce fichier car il contient un nombre de champs incorrect à la ligne %d. [org] org_name_holder=Nom de l'organisation @@ -1969,6 +2114,7 @@ create_org=Créer une organisation repo_updated=Mis à jour members=Membres teams=Équipes +code=Code lower_members=Membres lower_repositories=dépôts create_new_team=Nouvelle équipe @@ -1983,6 +2129,7 @@ team_permission_desc=Autorisation team_unit_desc=Permettre l’accès aux Sections du dépôt team_unit_disabled=(Désactivé) +form.name_pattern_not_allowed=Le motif "%s" n'est pas autorisé dans un nom d'organisation. form.create_org_not_allowed=Vous n'êtes pas autorisé à créer une organisation. settings=Paramètres @@ -1994,6 +2141,7 @@ settings.permission=Autorisations settings.repoadminchangeteam=L'administrateur de dépôt peut ajouter et supprimer l'accès aux équipes settings.visibility=Visibilité settings.visibility.public=Public +settings.visibility.limited=Limité (Visible uniquement aux utilisateurs authentifiés) settings.visibility.limited_shortname=Limité settings.visibility.private=Privé (Visible uniquement aux membres de l’organisation) settings.visibility.private_shortname=Privé @@ -2033,8 +2181,13 @@ teams.leave=Quitter teams.leave.detail=Quitter %s? teams.can_create_org_repo=Créer des dépôts teams.can_create_org_repo_helper=Les membres peuvent créer de nouveaux dépôts dans l'organisation. Le créateur obtiendra l'accès administrateur au nouveau dépôt. -teams.read_access=Lue(s) +teams.none_access=Aucun accès +teams.none_access_helper=Les membres ne peuvent voir ou faire quoi que ce soit sur cette partie. Sans effet pour les dépôts publics. +teams.general_access=Accès général +teams.general_access_helper=Les permissions des membres seront déterminées par la table des permissions ci-dessous. +teams.read_access=Lecture teams.read_access_helper=Les membres peuvent voir et cloner les dépôts de l'équipe. +teams.write_access=Écriture teams.write_access_helper=Les membres peuvent voir et pousser dans les dépôts de l'équipe. teams.admin_access=Accès Administrateur teams.admin_access_helper=Les membres peuvent tirer et pousser des modifications vers les dépôts de l'équipe, et y ajouter des collaborateurs. @@ -2045,6 +2198,8 @@ teams.members=Membres de L'Équipe teams.update_settings=Valider teams.delete_team=Supprimer l'équipe teams.add_team_member=Ajouter un Membre +teams.invite_team_member=Inviter à %s +teams.invite_team_member.list=Invitations en attente teams.delete_team_title=Supprimer l'équipe teams.delete_team_desc=Supprimer une équipe supprime l'accès aux dépôts à ses membres. Continuer ? teams.delete_team_success=L’équipe a été supprimée. @@ -2058,6 +2213,7 @@ teams.remove_all_repos_title=Supprimer tous les dépôts de l'équipe teams.remove_all_repos_desc=Ceci supprimera tous les dépôts de l'équipe. teams.add_all_repos_title=Ajouter tous les dépôts teams.add_all_repos_desc=Ceci ajoutera tous les dépôts de l'organisation à l'équipe. +teams.add_nonexistent_repo=Le dépôt que vous essayez d'ajouter n'existe pas, veuillez le créer d'abord. teams.add_duplicate_users=L’utilisateur est déjà un membre de l’équipe. teams.repos.none=Aucun dépôt n'est accessible par cette équipe. teams.members.none=Aucun membre dans cette équipe. @@ -2068,12 +2224,16 @@ teams.all_repositories_helper=L'équipe a accès à tous les dépôts. Sélectio teams.all_repositories_read_permission_desc=Cette équipe accorde l'accès en lecture à tous les dépôts : les membres peuvent voir et cloner les dépôts. teams.all_repositories_write_permission_desc=Cette équipe accorde l'accès en écriture à tous les dépôts : les membres peuvent lire et écrire dans les dépôts. teams.all_repositories_admin_permission_desc=Cette équipe accorde l'accès administrateur à tous les dépôts : les membres peuvent lire, écrire dans et ajouter des collaborateurs aux dépôts. +teams.invite.title=Vous avez été invité à rejoindre l’équipe %s dans l’organisation %s. +teams.invite.by=Invité par %s +teams.invite.description=Veuillez cliquer sur le bouton ci-dessous pour rejoindre l’équipe. [admin] dashboard=Tableau de bord users=Comptes utilisateurs organizations=Organisations repositories=Dépôts +hooks=Déclencheurs web authentication=Sources d'authentification emails=Courriels de l'utilisateur config=Configuration @@ -2083,6 +2243,7 @@ first_page=Première last_page=Dernière total=Total : %d +dashboard.new_version_hint=Gitea %s est maintenant disponible, vous utilisez %s. Consultez le blog pour plus de détails. dashboard.statistic=Résumé dashboard.operations=Opérations de maintenance dashboard.system_status=État du système @@ -2097,6 +2258,7 @@ dashboard.task.cancelled=Tâche: %[1]s a annulé: %[3]s dashboard.task.error=Erreur dans la tâche: %[1]s: %[3]s dashboard.task.finished=Tâche: %[1]s démarrée par %[2]s est terminée dashboard.task.unknown=Tâche inconnue: %[1]s +dashboard.cron.started=Tâche planifiée démarrée : %[1]s dashboard.cron.process=Tâche planifiée: %[1]s dashboard.cron.cancelled=Tâche planifiée : %s annulée : %[3]s dashboard.cron.error=Erreur dans la tâche planifiée : %s: %[3]s @@ -2120,6 +2282,7 @@ dashboard.resync_all_hooks=Re-synchroniser les déclencheurs Git pre-receive, up dashboard.reinit_missing_repos=Réinitialiser tous les dépôts Git manquants pour lesquels un enregistrement existe dashboard.sync_external_users=Synchroniser les données de l’utilisateur externe dashboard.cleanup_hook_task_table=Nettoyer la table hook_task +dashboard.cleanup_packages=Nettoyer des paquets expirés dashboard.server_uptime=Uptime du serveur dashboard.current_goroutine=Goroutines actuelles dashboard.current_memory_usage=Utilisation Mémoire actuelle @@ -2150,6 +2313,12 @@ dashboard.total_gc_pause=Pause GC dashboard.last_gc_pause=Dernière Pause GC dashboard.gc_times=Nombres de GC dashboard.delete_old_actions=Supprimer toutes les anciennes actions de la base de données +dashboard.delete_old_actions.started=Suppression de toutes les anciennes actions de la base de données démarrée. +dashboard.update_checker=Vérificateur de mise à jour +dashboard.delete_old_system_notices=Supprimer toutes les anciennes observations de la base de données +dashboard.stop_zombie_tasks=Arrêter les tâches zombies +dashboard.stop_endless_tasks=Arrêter les tâches sans fin +dashboard.cancel_abandoned_jobs=Annuler les jobs abandonnés users.user_manage_panel=Gestion du compte utilisateur users.new_account=Créer un compte @@ -2164,6 +2333,7 @@ users.created=Créés users.last_login=Dernière connexion users.never_login=Jamais connecté users.send_register_notify=Envoyer une notification d'inscription +users.new_success=Le compte "%s" a bien été créé. users.edit=Éditer users.auth_source=Sources d'authentification users.local=Locales @@ -2178,14 +2348,21 @@ users.prohibit_login=Désactiver la connexion users.is_admin=Est Administrateur users.is_restricted=Est restreint users.allow_git_hook=Autoriser la création de Git Hooks +users.allow_git_hook_tooltip=Les Déclencheurs Git sont exécutés par le même utilisateur que Gitea, qui a des privilèges systèmes élevés. Les utilisateurs ayant ce droit peuvent altérer touts les dépôts, compromettre la base de données applicative, et se promouvoir administrateurs de Gitea. users.allow_import_local=Autoriser l'importation de dépôts locaux users.allow_create_organization=Autoriser la création d'organisations users.update_profile=Modifier un compte users.delete_account=Supprimer cet utilisateur +users.cannot_delete_self=Vous ne pouvez pas vous supprimer vous-même users.still_own_repo=Cet utilisateur possède un ou plusieurs dépôts. Veuillez les supprimer ou les transférer à un autre utilisateur. users.still_has_org=Cet utilisateur est membre d'une organisation. Veuillez le retirer de toutes les organisations dont il est membre au préalable. +users.purge=Purger l'utilisateur +users.purge_help=Éradique l'utilisateur et tous ses dépôts, organisations, commentaires et paquets. +users.still_own_packages=Cet utilisateur possède encore un ou plusieurs paquets. Supprimez d’abord ces paquets. users.deletion_success=Le compte a été supprimé. users.reset_2fa=Réinitialiser l'authentification à deux facteurs +users.list_status_filter.menu_text=Filtrer +users.list_status_filter.reset=Réinitialiser users.list_status_filter.is_active=Actif users.list_status_filter.is_admin=Administrateur users.list_status_filter.is_restricted=Restreint @@ -2313,6 +2490,7 @@ auths.tip.yandex=`Créez une nouvelle application sur https://oauth.yandex.com/c auths.tip.mastodon=Entrez une URL d'instance personnalisée pour l'instance mastodon avec laquelle vous voulez vous authentifier (ou utiliser celle par défaut) auths.edit=Mettre à jour la source d'authentification auths.activated=Cette source d'authentification est activée +auths.new_success=L'authentification "%s" a été ajoutée. auths.update_success=La source d'authentification a été mise à jour. auths.update=Mettre à jour la source d'authentification auths.delete=Supprimer la source d'authentification @@ -2320,7 +2498,10 @@ auths.delete_auth_title=Suppression de la source d'authentification auths.delete_auth_desc=La suppression d'une source d'authentification empêche les utilisateurs de l'utiliser pour se connecter. Continuer ? auths.still_in_used=Cette source d'authentification est utilisée par un ou plusieurs utilisateurs, veuillez convertir ou supprimer ces comptes utilisateurs avant toute action. auths.deletion_success=La source d'authentification a été supprimée. +auths.login_source_exist=La source d'authentification "%s" existe déjà. auths.login_source_of_type_exist=Une source d'authentification de ce type existe déjà. +auths.unable_to_initialize_openid=Impossible d'initialiser le fournisseur OpenID Connect : %s +auths.invalid_openIdConnectAutoDiscoveryURL=URL de découverte automatique invalide (une URL valide commence par http:// ou https://) config.server_config=Configuration du serveur config.app_name=Titre du site @@ -2328,6 +2509,7 @@ config.app_ver=Version de Gitea config.app_url=URL de base de Gitea config.custom_conf=Chemin du fichier de configuration config.custom_file_root_path=Emplacement personnalisé du fichier racine +config.domain=Domaine du serveur config.offline_mode=Mode hors-ligne config.disable_router_log=Désactiver la Journalisation du Routeur config.run_user=Exécuter avec l'utilisateur @@ -2343,6 +2525,7 @@ config.reverse_auth_user=Annuler l'Authentification de l'Utilisateur config.ssh_config=Configuration SSH config.ssh_enabled=Activé config.ssh_start_builtin_server=Utiliser le serveur incorporé +config.ssh_domain=Domaine du serveur SSH config.ssh_port=Port config.ssh_listen_port=Port d'écoute config.ssh_root_path=Emplacement racine @@ -2393,16 +2576,23 @@ config.queue_length=Longueur de la file d'attente config.deliver_timeout=Expiration d'Envoi config.skip_tls_verify=Passer la vérification TLS +config.mailer_config=Configuration du service SMTP config.mailer_enabled=Activé +config.mailer_enable_helo=Activer HELO config.mailer_name=Nom +config.mailer_protocol=Protocole +config.mailer_smtp_addr=Adresse SMTP config.mailer_smtp_port=Port SMTP config.mailer_user=Utilisateur config.mailer_use_sendmail=Utiliser Sendmail config.mailer_sendmail_path=Chemin d’accès à Sendmail config.mailer_sendmail_args=Arguments supplémentaires pour Sendmail config.mailer_sendmail_timeout=Délai d’attente de Sendmail +config.mailer_use_dummy=Factice config.test_email_placeholder=E-mail (ex: test@example.com) config.send_test_mail=Envoyer un e-mail de test +config.test_mail_failed=Impossible d'envoyer un email de test à "%s" : %v +config.test_mail_sent=Un e-mail de test a été envoyé à "%s". config.oauth_config=Configuration OAuth config.oauth_enabled=Activé @@ -2432,6 +2622,8 @@ config.git_disable_diff_highlight=Désactiver la surbrillance syntaxique de Diff config.git_max_diff_lines=Lignes de Diff Max (pour un seul fichier) config.git_max_diff_line_characters=Nombre max de caractères de Diff (pour une seule ligne) config.git_max_diff_files=Nombre max de fichiers de Diff (à afficher) +config.git_enable_reflogs=Activer les reflogs +config.git_reflog_expiry_time=Délai d'expiration config.git_gc_args=Arguments de GC config.git_migrate_timeout=Délai imparti pour une migration config.git_mirror_timeout=Délai imparti pour mettre à jour le miroir @@ -2451,6 +2643,8 @@ config.access_log_template=Modèle config.xorm_log_mode=Mode de journalisation de XORM config.xorm_log_sql=Activer la journalisation SQL +config.get_setting_failed=Impossible d'obtenir le paramètre %s +config.set_setting_failed=Impossible de définir le paramètre %s monitor.cron=Tâches récurrentes monitor.name=Nom @@ -2459,12 +2653,16 @@ monitor.next=Suivant monitor.previous=Précédent monitor.execute_times=Exécutions monitor.process=Processus en cours d'exécution +monitor.stacktrace=Piles d'execution +monitor.goroutines=%d Goroutines monitor.desc=Description monitor.start=Heure de démarrage monitor.execute_time=Heure d'Éxécution +monitor.last_execution_result=Résultat monitor.process.cancel=Annuler le processus monitor.process.cancel_desc=L'annulation d'un processus peut entraîner une perte de données monitor.process.cancel_notices=Annuler : %s? +monitor.process.children=Enfant monitor.queues=Files d'attente monitor.queue=File d'attente : %s monitor.queue.name=Nom @@ -2472,6 +2670,7 @@ monitor.queue.type=Type monitor.queue.exemplar=Type d'exemple monitor.queue.numberworkers=Nombre de processus monitor.queue.maxnumberworkers=Nombre maximale de processus +monitor.queue.numberinqueue=Position dans la queue monitor.queue.review=Revoir la configuration monitor.queue.review_add=Réviser/Ajouter des processus monitor.queue.configuration=Configuration initiale @@ -2479,6 +2678,7 @@ monitor.queue.nopool.title=Pas de réservoir de processus monitor.queue.nopool.desc=Cette file d'attente contient d'autres files d'attente et ne possède pas de réservoir de processus. monitor.queue.wrapped.desc=Une file d'attente enveloppée, enveloppe une file d'attente de démarrage lente, mettant en mémoire tampon les requêtes dans un canal. Elle n'a pas de pool de processus elle-même. monitor.queue.persistable-channel.desc=Un canal persistant enveloppe deux files d'attente, une file d'attente de canal qui a son propre pool de processus et une file d'attente de niveau pour les requêtes persistantes des arrêts précédents. Il ne dispose pas d'un pool de travailleurs lui-même. +monitor.queue.flush=Évacuer la queue monitor.queue.pool.timeout=Expiration du délai monitor.queue.pool.addworkers.title=Ajouter un processus monitor.queue.pool.addworkers.submit=Ajouter un processus @@ -2491,6 +2691,12 @@ monitor.queue.pool.flush.title=Vider la file d'attente monitor.queue.pool.flush.desc=Vider va ajouter un processus qui se terminera une fois que la file d'attente est vide, ou qu'elle est en délai d'attente dépassé. monitor.queue.pool.flush.submit=Ajouter un processus de vidage monitor.queue.pool.flush.added=Processus de vidage ajouté pour %[1]s +monitor.queue.pool.pause.title=Suspendre la queue +monitor.queue.pool.pause.desc=Suspendre une queue l'empêchera de traiter les données +monitor.queue.pool.pause.submit=Suspendre la queue +monitor.queue.pool.resume.title=Reprendre la queue +monitor.queue.pool.resume.desc=Définit cette queue pour reprendre le travail +monitor.queue.pool.resume.submit=Reprendre la queue monitor.queue.settings.title=Paramètres du réservoir monitor.queue.settings.desc=Les pools se développent dynamiquement en réponse au blocage de la file d'attente des processus. Ces changements n'affecteront pas les groupes de processus actuels. @@ -2520,6 +2726,7 @@ monitor.queue.pool.cancel_desc=Quitter une file d'attente sans aucun groupe de p notices.system_notice_list=Informations notices.view_detail_header=Voir les détails de l'information système +notices.operations=Opérations notices.select_all=Tout Sélectionner notices.deselect_all=Tout désélectionner notices.inverse_selection=Inverser la sélection @@ -2535,12 +2742,15 @@ notices.delete_success=Les informations systèmes ont été supprimées. [action] create_repo=a créé le dépôt %s rename_repo=a rebaptisé le dépôt de %[1]s vers %[3]s +create_issue=`ticket ouvert %[3]s#%[2]s` +close_issue=`ticket fermé %[3]s#%[2]s` create_pull_request=`a créé la demande d'ajout %[3]s#%[2]s` close_pull_request=`a fermé la demande d'ajout %[3]s#%[2]s` reopen_pull_request=`a réouvert la demande d'ajout %[3]s#%[2]s` comment_pull=`a commenté la demande d'ajout %[3]s#%[2]s` merge_pull_request=`a fusionné la demande d'ajout %[3]s#%[2]s` transfer_repo=a transféré le dépôt %s à %s +push_tag=a poussé l'étiquette %[3]s vers %[4]s delete_tag=étiquette supprimée %[2]s de %[3]s delete_branch=branche %[2]s supprimée de %[3]s compare_branch=Comparer @@ -2588,6 +2798,9 @@ pin=Epingler la notification mark_as_read=Marquer comme lu mark_as_unread=Marquer comme non lue mark_all_as_read=Tout marquer comme lu +subscriptions=Abonnements +watching=Suivi +no_subscriptions=Pas d'abonnements [gpg] default_key=Signé avec la clé par défaut @@ -2601,35 +2814,216 @@ error.probable_bad_signature=AVERTISSEMENT ! Bien qu'il y ait une clé avec cet error.probable_bad_default_signature=AVERTISSEMENT ! Bien que la clé par défaut ait cet ID, elle ne vérifie pas cette livraison ! Cette livraison est SUSPECTE. [units] +unit=Unité error.no_unit_allowed_repo=Vous n'êtes pas autorisé à accéder à n'importe quelle section de ce dépôt. error.unit_not_allowed=Vous n'êtes pas autorisé à accéder à cette section du dépôt. [packages] +title=Paquets +desc=Gérer les paquets du dépôt. +empty=Il n'y pas de paquet pour le moment. +empty.documentation=Pour plus d'informations sur le registre de paquets, voir la documentation. empty.repo=Avez-vous téléchargé un paquet, mais il n'est pas affiché ici? Allez dans les paramètres du paquet et liez le à ce dépôt. filter.type=Type +filter.type.all=Tous +filter.no_result=Votre filtre n'affiche aucun résultat. +filter.container.tagged=Balisé +filter.container.untagged=Débalisé +published_by=%[1]s publié par %[3]s published_by_in=%[1]s publié par %[3]s en %[5]s +installation=Installation +about=À propos de ce paquet +requirements=Exigences +dependencies=Dépendances +keywords=Mots-clés +details=Détails +details.author=Auteur +details.project_site=Site du projet +details.repository_site=Site du dépôt +details.documentation_site=Site de documentation +details.license=Licence +assets=Ressources +versions=Versions +versions.view_all=Voir tout +dependency.id=ID +dependency.version=Version +cargo.install=Pour installer le paquet en utilisant Cargo, exécutez la commande suivante : +cargo.documentation=Pour plus d'informations sur le registre Cargo, voir la documentation. +cargo.details.repository_site=Site du dépôt +cargo.details.documentation_site=Site de documentation +chef.registry=Configurer ce registre dans votre fichier ~/.chef/config.rb: +chef.install=Pour installer le paquet, exécutez la commande suivante : +chef.documentation=Pour plus d'informations sur le registre Chef, voir la documentation. +composer.registry=Configurez ce registre dans votre fichier ~/.composer/config.json : +composer.install=Pour installer le paquet en utilisant Composer, exécutez la commande suivante : +composer.documentation=Pour plus d'informations sur le registre Composer voir la documentation. +composer.dependencies=Dépendances +composer.dependencies.development=Dépendances de développement conan.details.repository=Dépôt +conan.registry=Configurez ce registre à partir d'un terminal : +conan.install=Pour installer le paquet en utilisant Conan, exécutez la commande suivante : +conan.documentation=Pour plus d'informations sur le registre Conan, voir la documentation. +conda.documentation=Pour plus d'informations sur le registre Conda, voir la documentation. +conda.details.repository_site=Site du dépôt +conda.details.documentation_site=Site de documentation +container.details.type=Type d'image +container.details.platform=Plateforme +container.pull=Tirez l'image depuis un terminal : +container.digest=Empreinte : +container.documentation=Pour plus d'informations sur le registre Container, voir la documentation. +container.multi_arch=SE / Arch +container.layers=Calques d'image +container.labels=Étiquettes +container.labels.key=Clé +container.labels.value=Valeur +generic.download=Télécharger le paquet depuis un terminal : +generic.documentation=Pour plus d'informations sur le registre générique, voir la documentation. +helm.registry=Configurer ce registre à partir d'un terminal : +helm.install=Pour installer le paquet, exécutez la commande suivante : +helm.documentation=Pour plus d'informations sur le registre Helm, voir la documentation. +maven.install2=Exécuter dans un terminal : +maven.documentation=Pour plus d'informations sur le registre Maven, voir la documentation. +nuget.registry=Configurer ce registre à partir d'un terminal : +nuget.documentation=Pour plus d'informations sur le registre NuGet, voir la documentation. +nuget.dependency.framework=Cadriciel cible +npm.install2=ou ajoutez-le au fichier package.json : +npm.documentation=Pour plus d'informations sur le registre npm, voir la documentation. +npm.dependencies=Dépendances +npm.dependencies.development=Dépendances de développement +npm.dependencies.peer=Dépendances de pairs +npm.dependencies.optional=Dépendances optionnelles +npm.details.tag=Balise +pub.documentation=Pour davantage d'informations sur le registre Pub, voir la documentation. +pypi.requires=Nécessite Python +pypi.documentation=Pour plus d'informations sur le registre PyPI, voir la documentation. +rubygems.install2=ou ajoutez-le au Gemfile : +rubygems.dependencies.runtime=Dépendances d'exécution +rubygems.dependencies.development=Dépendances de développement rubygems.required.ruby=Nécessite la version de Ruby rubygems.required.rubygems=Nécessite la version de RubyGem +rubygems.documentation=Pour plus d'informations sur le registre RubyGems, consulter la documentation. +swift.registry=Configurez ce registre à partir d'un terminal : +swift.install=Ajoutez le paquet dans votre fichier Package.swift: +swift.install2=et exécutez la commande suivante : +swift.documentation=Pour plus d'informations sur le registre Swift, voir la documentation. +vagrant.documentation=Pour plus d'informations sur le registre Vagrant, voir la documentation. +settings.link=Lier ce paquet à un dépôt +settings.link.select=Sélectionner un dépôt +settings.link.button=Actualiser le lien du dépôt +settings.link.success=Le lien du dépôt a été mis à jour avec succès. +settings.link.error=Impossible de mettre à jour le lien du dépôt. +settings.delete=Supprimer le paquet +settings.delete.success=Le paquet a été supprimé. +settings.delete.error=Impossible de supprimer le paquet. +owner.settings.cargo.title=Index du Registre Cargo +owner.settings.cargo.initialize=Initialiser l'index +owner.settings.cargo.initialize.description=Pour utiliser le registre Cargo, un dépôt git d'index spécial est nécessaire. Ici, vous pouvez le (re)créer avec la configuration requise. +owner.settings.cargo.initialize.error=Impossible d'initialiser l'index de Cargo : %v +owner.settings.cargo.initialize.success=L'index Cargo a été créé avec succès. +owner.settings.cargo.rebuild=Reconstruire l'index +owner.settings.cargo.rebuild.description=Si l’index est désynchronisé avec les paquets cargo stockés, vous pouvez le reconstruire ici. +owner.settings.cargo.rebuild.error=Impossible de reconstruire l'index Cargo : %v +owner.settings.cargo.rebuild.success=L'index Cargo a été reconstruit avec succès. +owner.settings.cleanuprules.title=Gérer les règles de nettoyage +owner.settings.cleanuprules.add=Ajouter une règle de nettoyage +owner.settings.cleanuprules.edit=Modifier la règle de nettoyage +owner.settings.cleanuprules.none=Aucune règle de nettoyage disponible. Consultez la documentation pour en savoir plus. +owner.settings.cleanuprules.preview=Aperçu des règles de nettoyage +owner.settings.cleanuprules.preview.overview=%d paquets sont programmés pour être supprimés. +owner.settings.cleanuprules.preview.none=La règle de nettoyage ne correspond à aucun paquet. owner.settings.cleanuprules.enabled=Activé +owner.settings.cleanuprules.pattern_full_match=Appliquer le modèle au nom complet du paquet +owner.settings.cleanuprules.keep.title=Les versions qui correspondent à ces règles sont conservées, même si elles correspondent à une règle de suppression ci-dessous. +owner.settings.cleanuprules.keep.count=Garder le plus récent +owner.settings.cleanuprules.keep.count.1=1 version par paquet +owner.settings.cleanuprules.keep.count.n=%d versions par paquet +owner.settings.cleanuprules.keep.pattern=Garder les versions correspondantes +owner.settings.cleanuprules.keep.pattern.container=La version la plus récente est toujours conservée pour les paquets Container. +owner.settings.cleanuprules.remove.title=Les versions qui correspondent à ces règles sont supprimées, sauf si une règle ci-dessus dit de les garder. +owner.settings.cleanuprules.remove.days=Supprimer les versions antérieures à +owner.settings.cleanuprules.remove.pattern=Supprimer les versions correspondantes +owner.settings.cleanuprules.success.update=La règle de nettoyage a été mise à jour. +owner.settings.cleanuprules.success.delete=La règle de nettoyage a été supprimée. +owner.settings.chef.title=Dépôt Chef +owner.settings.chef.keypair=Générer une paire de clés +owner.settings.chef.keypair.description=Génère une paire de clés utilisée pour s’authentifier auprès du registre Chef. La précédente clé ne pourra plus être utilisée. [secrets] +secrets=Secrets +description=Les secrets seront transmis à certaines actions et ne pourront pas être lus autrement. +none=Il n'y a pas encore de secrets. +value=Valeur name=Nom +creation=Ajouter un secret +creation.name_placeholder=Caractères alphanumériques ou tirets bas uniquement, insensibles à la casse, ne peut commencer par GITEA_ ou GITHUB_ +creation.success=Le secret "%s" a été ajouté. +creation.failed=Impossible d'ajouter le secret. +deletion=Supprimer le secret +deletion.description=La suppression d'un secret est permanente et ne peut pas être annulée. Continuer ? +deletion.success=Le secret a été supprimé. +deletion.failed=Impossible de supprimer le secret. [actions] - - - +actions=Actions + +unit.desc=Gérer les actions + +status.unknown=Inconnu +status.waiting=En attente +status.running=En cours d'exécution +status.success=Succès +status.failure=Échec +status.cancelled=Annulé +status.skipped=Ignoré +status.blocked=Bloqué + +runners=Exécuteurs +runners.runner_manage_panel=Gestion des exécuteurs +runners.new=Créer un nouvel exécuteur +runners.new_notice=Comment démarrer un exécuteur +runners.status=Statut +runners.id=ID runners.name=Nom runners.owner_type=Type runners.description=Description +runners.labels=Étiquettes +runners.last_online=Dernière fois en ligne +runners.agent_labels=Étiquettes de l'agent +runners.custom_labels=Étiquettes personnalisées +runners.custom_labels_helper=Les libellés personnalisées sont des libellés ajoutées manuellement par un administrateur. Ils sont séparés par des virgules et les blancs les cernant seront taillés. +runners.runner_title=Exécuteur +runners.task_list=Tâches récentes sur cet exécuteur runners.task_list.run=Exécuter +runners.task_list.status=Statut runners.task_list.repository=Dépôt runners.task_list.commit=Commit +runners.task_list.done_at=Fait à +runners.edit_runner=Éditer l'Exécuteur +runners.update_runner=Mettre à jour les modifications +runners.update_runner_success=Exécuteur mis à jour avec succès +runners.update_runner_failed=Impossible de mettre à jour l'Exécuteur +runners.delete_runner=Supprimer cet exécuteur +runners.delete_runner_success=Exécuteur supprimé avec succès +runners.delete_runner_failed=Impossible de supprimer l'Exécuteur +runners.delete_runner_header=Êtes-vous sûr de vouloir supprimer cet exécuteur ? +runners.none=Aucun exécuteur disponible +runners.status.unspecified=Inconnu +runners.status.idle=Inactif runners.status.active=Actif +runners.status.offline=Hors-ligne +runners.version=Version +runners.reset_registration_token_success=Le jeton d’inscription de l’exécuteur a été réinitialisé avec succès +runs.open_tab=%d Ouvert +runs.closed_tab=%d Fermé runs.commit=Commit +runs.pushed_by=Poussée par +runs.no_matching_runner_helper=Aucun exécuteur correspondant : %s +need_approval_desc=Besoin d'approbation pour exécuter des workflows pour une demande de fusion de bifurcation. [projects] +type-1.display_name=Projet personnel +type-2.display_name=Projet de dépôt +type-3.display_name=Projet d’organisation diff --git a/options/locale/locale_tr-TR.ini b/options/locale/locale_tr-TR.ini index 4b8c1d51a3937..76c52da6783e7 100644 --- a/options/locale/locale_tr-TR.ini +++ b/options/locale/locale_tr-TR.ini @@ -462,6 +462,7 @@ team_invite.text_3=Not: Bu davet %[1] içindi. Bu daveti beklemiyorsanız, e-pos [modal] yes=Evet no=Hayır +confirm=Onayla cancel=İptal modify=Güncelle @@ -1618,7 +1619,10 @@ pulls.tab_files=Değiştirilen Dosyalar pulls.reopen_to_merge=Lütfen birleştirme gerçekleştirmek için bu değişiklik isteğini yeniden açın. pulls.cant_reopen_deleted_branch=Dal silindiğinden bu değişiklik isteği yeniden açılamaz. pulls.merged=Birleştirildi +pulls.merged_success=Değişiklik isteği başarıyla birleştirildi ve kapatıldı +pulls.closed=Değişiklik isteği kapatıldı pulls.manually_merged=Elle birleştirildi +pulls.merged_info_text=%s dalı şimdi silinebilir. pulls.is_closed=Değişiklik isteği kapatıldı. pulls.title_wip_desc=`Değişiklik isteğinin yanlışlıkla birleştirilmesini önlemek için, başlığı %s ile başlatın` pulls.cannot_merge_work_in_progress=Bu değişiklik isteği, devam eden bir çalışma olarak işaretlendi. @@ -3221,6 +3225,7 @@ cargo.details.repository_site=Depo Sitesi cargo.details.documentation_site=Belge Sitesi chef.registry=Bu kütüğü ~/.chef/config.rb dosyasında ayarlayın: chef.install=Paketi kurmak için, aşağıdaki komutu çalıştırın: +chef.documentation=Chef kütüğü hakkında daha fazla bilgi için, belgeye bakabilirsiniz. composer.registry=Bu kütüğü ~/.composer/config.json dosyasında ayarlayın: composer.install=Paketi Composer ile kurmak için, şu komutu çalıştırın: composer.documentation=Composer kütüğü hakkında daha fazla bilgi için, belgeye bakabilirsiniz. @@ -3232,6 +3237,7 @@ conan.install=Conan ile paket kurmak için aşağıdaki komutu çalıştırın: conan.documentation=Conan kütüğü hakkında daha fazla bilgi için, belgeye bakabilirsiniz. conda.registry=Bu kütüğü .condarc dosyasında bir Conda deposu olarak ayarlayın: conda.install=Conda ile paket kurmak için aşağıdaki komutu çalıştırın: +conda.documentation=Conda kütüğü hakkında daha fazla bilgi için, belgeye bakabilirsiniz. conda.details.repository_site=Depo Sitesi conda.details.documentation_site=Belge Sitesi container.details.type=Görüntü Türü @@ -3282,6 +3288,7 @@ rubygems.documentation=RubyGems kütüğü hakkında daha fazla bilgi için, Package.swift dosyanıza ekleyin: swift.install2=ve şu komutu çalıştırın: +swift.documentation=Swift kütüğü hakkında daha fazla bilgi için, belgeye bakabilirsiniz. vagrant.install=Vagrant paketi eklemek için aşağıdaki komutu çalıştırın: vagrant.documentation=Vagrant kütüğü hakkında daha fazla bilgi için, belgeye bakabilirsiniz. settings.link=Bu paketi bir depoya bağlayın From 32d9c47ec7706d8f06e09b42e09a28d7a0e3c526 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sat, 20 May 2023 23:02:52 +0200 Subject: [PATCH 12/27] Add RTL rendering support to Markdown (#24816) Support RTL content in Markdown: ![image](https://github.com/go-gitea/gitea/assets/115237/dedb1b0c-2f05-40dc-931a-0d9dc81f7c97) Example document: https://try.gitea.io/silverwind/symlink-test/src/branch/master/bidi-text.md Same on GitHub: https://github.com/silverwind/symlink-test/blob/master/bidi-text.md `dir=auto` enables a browser heuristic that sets the text direction automatically. It is the only way to get automatic text direction. Ref: https://codeberg.org/Codeberg/Community/issues/1021 --------- Co-authored-by: wxiaoguang --- modules/markup/html.go | 2 +- modules/markup/markdown/goldmark.go | 10 ++++++++++ modules/markup/renderer.go | 6 ++++-- services/markup/processorhelper.go | 1 + tests/integration/user_test.go | 2 +- web_src/css/base.css | 1 + web_src/css/markup/content.css | 6 +++++- 7 files changed, 23 insertions(+), 5 deletions(-) diff --git a/modules/markup/html.go b/modules/markup/html.go index 11888b8536353..da16bcd3cb3fe 100644 --- a/modules/markup/html.go +++ b/modules/markup/html.go @@ -630,7 +630,7 @@ func mentionProcessor(ctx *RenderContext, node *html.Node) { } mentionedUsername := mention[1:] - if processorHelper.IsUsernameMentionable != nil && processorHelper.IsUsernameMentionable(ctx.Ctx, mentionedUsername) { + if DefaultProcessorHelper.IsUsernameMentionable != nil && DefaultProcessorHelper.IsUsernameMentionable(ctx.Ctx, mentionedUsername) { replaceContent(node, loc.Start, loc.End, createLink(util.URLJoin(setting.AppURL, mentionedUsername), mention, "mention")) node = node.NextSibling.NextSibling } else { diff --git a/modules/markup/markdown/goldmark.go b/modules/markup/markdown/goldmark.go index 816e93b700fa4..f03a780900356 100644 --- a/modules/markup/markdown/goldmark.go +++ b/modules/markup/markdown/goldmark.go @@ -47,6 +47,12 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa tocMode = rc.TOC } + applyElementDir := func(n ast.Node) { + if markup.DefaultProcessorHelper.ElementDir != "" { + n.SetAttributeString("dir", []byte(markup.DefaultProcessorHelper.ElementDir)) + } + } + attentionMarkedBlockquotes := make(container.Set[*ast.Blockquote]) _ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { @@ -69,6 +75,9 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa header.ID = util.BytesToReadOnlyString(id.([]byte)) } tocList = append(tocList, header) + applyElementDir(v) + case *ast.Paragraph: + applyElementDir(v) case *ast.Image: // Images need two things: // @@ -171,6 +180,7 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa v.AppendChild(v, newChild) } } + applyElementDir(v) case *ast.Text: if v.SoftLineBreak() && !v.HardLineBreak() { renderMetas := pc.Get(renderMetasKey).(map[string]string) diff --git a/modules/markup/renderer.go b/modules/markup/renderer.go index f2477f1e9ee9f..0331c3742ab11 100644 --- a/modules/markup/renderer.go +++ b/modules/markup/renderer.go @@ -30,14 +30,16 @@ const ( type ProcessorHelper struct { IsUsernameMentionable func(ctx context.Context, username string) bool + + ElementDir string // the direction of the elements, eg: "ltr", "rtl", "auto", default to no direction attribute } -var processorHelper ProcessorHelper +var DefaultProcessorHelper ProcessorHelper // Init initialize regexps for markdown parsing func Init(ph *ProcessorHelper) { if ph != nil { - processorHelper = *ph + DefaultProcessorHelper = *ph } NewSanitizer() diff --git a/services/markup/processorhelper.go b/services/markup/processorhelper.go index 2897f203a90fb..3551f85c4668c 100644 --- a/services/markup/processorhelper.go +++ b/services/markup/processorhelper.go @@ -13,6 +13,7 @@ import ( func ProcessorHelper() *markup.ProcessorHelper { return &markup.ProcessorHelper{ + ElementDir: "auto", // set dir="auto" for necessary (eg:

    , , etc) tags IsUsernameMentionable: func(ctx context.Context, username string) bool { mentionedUser, err := user.GetUserByName(ctx, username) if err != nil { diff --git a/tests/integration/user_test.go b/tests/integration/user_test.go index fa8e6e85c7a3a..65cba1dee3e31 100644 --- a/tests/integration/user_test.go +++ b/tests/integration/user_test.go @@ -250,7 +250,7 @@ func TestGetUserRss(t *testing.T) { title, _ := rssDoc.ChildrenFiltered("title").Html() assert.EqualValues(t, "Feed of "the_1-user.with.all.allowedChars"", title) description, _ := rssDoc.ChildrenFiltered("description").Html() - assert.EqualValues(t, "<p>some <a href="https://commonmark.org/" rel="nofollow">commonmark</a>!</p>\n", description) + assert.EqualValues(t, "<p dir="auto">some <a href="https://commonmark.org/" rel="nofollow">commonmark</a>!</p>\n", description) } } diff --git a/web_src/css/base.css b/web_src/css/base.css index 36624ab957628..6c6c5381ad73e 100644 --- a/web_src/css/base.css +++ b/web_src/css/base.css @@ -1091,6 +1091,7 @@ a.label, color: var(--color-text); background: var(--color-box-body); border-color: var(--color-secondary); + text-align: start; /* Override fomantic's `text-align: left` to make RTL work via HTML `dir="auto"` */ } .ui.table th, diff --git a/web_src/css/markup/content.css b/web_src/css/markup/content.css index db67ac4263d26..5b2d6ef2441d5 100644 --- a/web_src/css/markup/content.css +++ b/web_src/css/markup/content.css @@ -23,9 +23,9 @@ } .markup .anchor { + float: left; padding-right: 4px; margin-left: -20px; - line-height: 1; color: inherit; } @@ -37,6 +37,10 @@ outline: none; } +.markup h1 .anchor { + margin-top: -2px; /* re-align to center */ +} + .markup h1 .anchor .svg, .markup h2 .anchor .svg, .markup h3 .anchor .svg, From 6ba4f897231229c06ac98bf2e067665e3ef0bf23 Mon Sep 17 00:00:00 2001 From: GiteaBot Date: Sun, 21 May 2023 00:26:26 +0000 Subject: [PATCH 13/27] [skip ci] Updated translations via Crowdin --- options/locale/locale_fr-FR.ini | 346 +++++++++++++++++++++++++++++--- 1 file changed, 318 insertions(+), 28 deletions(-) diff --git a/options/locale/locale_fr-FR.ini b/options/locale/locale_fr-FR.ini index d7351acb190c8..8eed8318481f2 100644 --- a/options/locale/locale_fr-FR.ini +++ b/options/locale/locale_fr-FR.ini @@ -603,6 +603,9 @@ password_username_disabled=Les utilisateurs externes ne sont pas autorisés à m full_name=Non Complet website=Site Web location=Localisation +update_theme=Modifier le thème +update_profile=Modifier le profil +update_language=Modifier la langue update_language_not_found=La langue "%s" n'est pas disponible. update_language_success=La langue a été mise à jour. update_profile_success=Votre profil a été mis à jour. @@ -638,6 +641,7 @@ lookup_avatar_by_mail=Rechercher un avatar par adresse e-mail federated_avatar_lookup=Recherche d'avatars fédérés enable_custom_avatar=Utiliser un avatar personnalisé choose_new_avatar=Sélectionner un nouvel avatar +update_avatar=Modifier l’avatar delete_current_avatar=Supprimer l'avatar actuel uploaded_avatar_not_a_image=Le fichier téléchargé n'est pas une image. uploaded_avatar_is_too_big=Le fichier téléchargé dépasse la taille limite. @@ -661,10 +665,23 @@ primary=Principale activated=Activé requires_activation=Nécessite une activation primary_email=Faire de cette adresse votre adresse principale +activate_email=Envoyer l’activation +activations_pending=Activations en attente delete_email=Exclure +email_deletion=Supprimer l'adresse e-mail +email_deletion_desc=L’adresse e-mail et les informations associées seront retirées de votre compte. Les révisions Git effectuées par cette adresse resteront inchangées. Continuer ? +email_deletion_success=L'adresse e-mail a été supprimée. +theme_update_success=Votre thème a été mis à jour. +theme_update_error=Le thème sélectionné n'existe pas. +openid_deletion=Supprimer l’adresse OpenID +openid_deletion_desc=Supprimer cette adresse OpenID de votre compte vous empêchera de vous connecter avec. Continuer ? openid_deletion_success=L'adresse OpenID a été supprimée. +add_new_email=Ajouter une nouvelle adresse e-mail add_new_openid=Ajouter une nouvelle URI OpenID +add_email=Ajouter une adresse e-mail add_openid=Ajouter une URI OpenID +add_email_confirmation_sent=Un e-mail de confirmation a été envoyé à "%s". Veuillez vérifier votre boîte de réception dans les %s suivants pour confirmer votre adresse e-mail. +add_email_success=La nouvelle adresse e-mail a été ajoutée. email_preference_set_success=L'e-mail de préférence a été défini avec succès. add_openid_success=La nouvelle adresse OpenID a été ajoutée. keep_email_private=Cacher l'adresse e-mail @@ -717,14 +734,45 @@ key_id=ID de la clé key_name=Nom de la Clé key_content=Contenu principal_content=Contenu +add_key_success=La clé SSH "%s" a été ajoutée. +add_gpg_key_success=La clé GPG "%s" a été ajoutée. +add_principal_success=Le Principal de certificat SSH "%s" a été ajouté. delete_key=Exclure +ssh_key_deletion=Retirer la clé SSH +gpg_key_deletion=Retirer la clé GPG +ssh_principal_deletion=Retirer le Principal de certificat SSH +ssh_key_deletion_desc=Le retrait d'une clé SSH révoque son accès à votre compte. Continuer ? gpg_key_deletion_desc=Supprimer une clé GPG renie les révisions signées par celle-ci. Continuer ? +ssh_principal_deletion_desc=Le retrait d'un Principal de certificat SSH révoque son accès à votre compte. Poursuivre ? +ssh_key_deletion_success=La clé SSH a été retirée. +gpg_key_deletion_success=La clé GPG a été retirée. +ssh_principal_deletion_success=Le Principal a été retiré. +add_on=Ajouté le +valid_until=Valable jusqu'au +last_used=Dernière utilisation le +no_activity=Aucune activité récente can_read_info=Lue(s) can_write_info=Écriture - +key_state_desc=Cette clé a été utilisée au cours des 7 derniers jours +token_state_desc=Ce jeton a été utilisé au cours des 7 derniers jours +principal_state_desc=Ce Principal a été utilisé au cours des 7 derniers jours +show_openid=Afficher sur le profil +hide_openid=Masquer du profil +ssh_disabled=SSH désactivé +ssh_signonly=Le SSH est actuellement désactivé, donc ces clés ne sont utilisées que pour la vérification de la signature de livraison. +unbind=Dissocier + +manage_access_token=Gérer les jetons d'accès +generate_new_token=Générer un nouveau jeton +token_name=Nom du jeton +generate_token=Générer un jeton +generate_token_success=Votre nouveau jeton a été généré. Copiez-le maintenant car il ne sera plus affiché. +generate_token_name_duplicate=%s a déjà été utilisé comme nom d'application. Veuillez en utiliser un autre. delete_token=Supprimer +access_token_deletion=Supprimer le jeton d'accès access_token_deletion_cancel_action=Annuler access_token_deletion_confirm_action=Supprimer +access_token_deletion_desc=Supprimer un jeton révoquera l'accès à votre compte pour toutes les applications l'utilisant. Cette action est irréversible. Continuer ? delete_token_success=Ce jeton a été supprimé. Les applications l'utilisant n'ont plus accès à votre compte. select_scopes=Sélectionner les périmètres scopes_list=Périmètres : @@ -752,22 +800,57 @@ oauth2_application_create_description=Les applications OAuth2 permettent à votr oauth2_application_remove_description=La suppression d'une application OAuth2 l'empêchera d'accéder aux comptes d'utilisateurs autorisés sur cette instance. Poursuivre ? authorized_oauth2_applications=Applications OAuth2 autorisées - - - +revoke_key=Révoquer +revoke_oauth2_grant=Révoquer l'accès +revoke_oauth2_grant_description=La révocation de l'accès à cette application tierce l'empêchera d'accéder à vos données. Vous êtes sûr ? +revoke_oauth2_grant_success=Vous avez révoqué l'accès avec succès. + +twofa_desc=L'authentification à deux facteurs améliore la sécurité de votre compte. +twofa_scratch_token_regenerate=Régénérer un jeton de secours +twofa_scratch_token_regenerated=Votre jeton de secours est maintenant %s. Gardez-le en lieu sûr. +twofa_enroll=Activer l'authentification à deux facteurs +twofa_disable_note=Vous pouvez désactiver l'authentification à deux facteurs si nécessaire. +twofa_disable_desc=Désactiver l'authentification à deux facteurs rendra votre compte plus vulnérable. Confirmer ? +twofa_disabled=L'authentification à deux facteurs a été désactivée. +twofa_failed_get_secret=Impossible d'obtenir le secret. + +webauthn_register_key=Ajouter une clé de sécurité +webauthn_nickname=Pseudonyme +webauthn_delete_key=Retirer la clé de sécurité + +manage_account_links=Gérer les comptes liés +manage_account_links_desc=Ces comptes externes sont liés à votre compte Gitea. +account_links_not_available=Il n'y a pour l'instant pas de compte externe connecté à votre compte Gitea. link_account=Lier un Compte +remove_account_link=Supprimer un compte lié +remove_account_link_desc=La suppression d'un compte lié révoquera son accès à votre compte Gitea. Continuer ? +remove_account_link_success=Le compte lié a été supprimé. +hooks.desc=Ajoute des déclencheurs qui seront activés pour tous les dépôts de cet utilisateur. +orgs_none=Vous n'êtes membre d'aucune organisation. +repos_none=Vous ne possédez aucun dépôt +delete_account=Supprimer votre compte +delete_prompt=Cette opération supprimera définitivement votre compte d'utilisateur. Cette action est IRRÉVERSIBLE. +delete_with_all_comments=Votre compte est plus jeune que %s. Afin d'éviter les commentaires fantômes, tous les commentaires sur les tickets/PR seront aussi supprimés. confirm_delete_account=Confirmez la suppression delete_account_title=Supprimer cet utilisateur +delete_account_desc=Êtes-vous sûr de vouloir supprimer définitivement ce compte d'utilisateur ? email_notifications.enable=Activer les notifications par e-mail +email_notifications.onmention=N'envoyer un e-mail que si vous êtes mentionné email_notifications.disable=Désactiver les notifications par e-mail +email_notifications.submit=Définir les préférences d'e-mail +email_notifications.andyourown=Et vos propres notifications +visibility=Visibilité de l'utilisateur visibility.public=Public +visibility.public_tooltip=Visible par tout le monde visibility.limited=Limité +visibility.limited_tooltip=Visible uniquement pour les utilisateurs authentifiés visibility.private=Privé +visibility.private_tooltip=Visible uniquement aux membres de l'organisation [repo] new_repo_helper=Un dépôt contient tous les fichiers du projet, y compris l'historique des révisions. Vous l'avez déjà ailleurs ? Migrer le dépôt. @@ -854,6 +937,7 @@ delete_preexisting=Supprimer les fichiers préexistants delete_preexisting_content=Supprimer les fichiers dans %s delete_preexisting_success=Supprimer les fichiers non adoptés dans %s blame_prior=Voir le blame avant cette modification +author_search_tooltip=Affiche un maximum de 30 utilisateurs transfer.accept=Accepter le transfert transfer.accept_desc=`Transférer à "%s"` @@ -887,7 +971,13 @@ archive.pull.nocomment=Ce dépôt est archivé. Vous ne pouvez pas commenter de form.reach_limit_of_creation_1=Vous avez déjà atteint la limite d'%d dépôt. form.reach_limit_of_creation_n=Vous avez déjà atteint la limite de %d dépôts. +form.name_reserved=Ce nom de dépôt "%s" est réservé. +form.name_pattern_not_allowed=Le motif "%s" n'est pas autorisé dans un nom de dépôt. +need_auth=Autorisation +migrate_options=Options de migration +migrate_service=Service de migration +migrate_options_lfs=Migrer les fichiers LFS migrate_options_lfs_endpoint.label=Point d'accès LFS migrate_options_lfs_endpoint.description.local=Un chemin de serveur local est également pris en charge. migrate_options_lfs_endpoint.placeholder=Laisser vide pour dériver de l'URL de clonage @@ -945,10 +1035,12 @@ unstar=Retirer des favoris star=Ajouter aux favoris fork=Bifurcation download_archive=Télécharger ce dépôt +more_operations=Plus d'opérations no_desc=Aucune description quick_guide=Introduction rapide clone_this_repo=Cloner ce dépôt +cite_this_repo=Citer ce dépôt create_new_repo_command=Création d'un nouveau dépôt en ligne de commande push_exist_repo=Soumission d'un dépôt existant par ligne de commande empty_message=Ce dépôt n'a pas de contenu. @@ -979,6 +1071,7 @@ release=Versions releases=Versions tag=Tag released_this=a publié ceci +tagged_this=a étiqueté file.title=%s sur %s file_raw=Brut file_history=Historique @@ -990,6 +1083,7 @@ file_too_large=Le fichier est trop gros pour être affiché. invisible_runes_header=`Ce fichier contient des caractères Unicode invisibles !` invisible_runes_description=`Ce fichier contient des caractères Unicode invisibles qui pourraient être affichés différemment de ce qui apparaît ci-dessous. Si votre cas d'utilisation est intentionnel et légitime, vous pouvez ignorer en toute sécurité cet avertissement. Utilisez le bouton Échapper pour mettre en évidence ces caractères invisbles.` ambiguous_runes_header=`Ce fichier contient des caractères Unicode ambigus !` +ambiguous_runes_description=`Ce fichier contient des caractères Unicode ambigus qui peuvent être confondus avec d'autres dans votre locale actuelle. Si votre cas d'utilisation est intentionnel et légitime, vous pouvez ignorer cet avertissement. Utilisez le bouton Echap pour mettre en évidence ces caractères.` invisible_runes_line=`Cette ligne contient des caractères Unicode invisibles` ambiguous_runes_line=`Cette ligne contient des caractères Unicode ambigus` ambiguous_character=`%[1]c [U+%04[1]X] peut être confondu avec %[2]c [U+%04[2]X]` @@ -997,6 +1091,7 @@ ambiguous_character=`%[1]c [U+%04[1]X] peut être confondu avec %[2]c [U+%04[2]X escape_control_characters=Échapper unescape_control_characters=Annuler l'échappement file_copy_permalink=Copier le lien permanent +view_git_blame=Voir Git Blâme video_not_supported_in_browser=Votre navigateur ne supporte pas le tag HTML5 "video". audio_not_supported_in_browser=Votre navigateur ne supporte pas la balise « audio » HTML5. stored_lfs=Stocké avec Git LFS @@ -1011,6 +1106,7 @@ download_file=Télécharger le fichier normal_view=Vue normale line=ligne lines=lignes +from_comment=(commentaire) editor.add_file=Ajouter un fichier editor.new_file=Nouveau fichier @@ -1025,6 +1121,7 @@ editor.must_be_on_a_branch=Vous devez être sur une branche pour appliquer ou pr editor.fork_before_edit=Vous devez faire bifurquer ce dépôt pour appliquer ou proposer des modifications à ce fichier. editor.delete_this_file=Supprimer le fichier editor.must_have_write_access=Vous devez avoir un accès en écriture pour appliquer ou proposer des modifications à ce fichier. +editor.file_delete_success=Le fichier "%s" a été supprimé. editor.name_your_file=Nommez votre fichier… editor.filename_help=Ajoutez un dossier en entrant son nom suivi d'une barre oblique ('/'). Supprimez un dossier avec un retour arrière au début du champ. editor.or=ou @@ -1032,8 +1129,12 @@ editor.cancel_lower=Annuler editor.commit_signed_changes=Valider les révisions signées editor.commit_changes=Enregistrer les modifications editor.add_tmpl=Ajouter '' +editor.add=Ajouter %s +editor.update=Actualiser %s +editor.delete=Supprimer %s editor.patch=Appliquer le correctif editor.patching=Correction: +editor.fail_to_apply_patch=`Impossible d'appliquer le correctif "%s"` editor.new_patch=Nouveau correctif editor.commit_message_desc=Ajouter une description détaillée facultative… editor.signoff_desc=Ajout d'un trailer Signed-off-by par le committeur à la fin du message du journal de commit. @@ -1045,15 +1146,26 @@ editor.new_branch_name=Nommer la nouvelle branche pour cette révision editor.new_branch_name_desc=Nouveau nom de la branche… editor.cancel=Annuler editor.filename_cannot_be_empty=Le nom de fichier ne peut être vide. +editor.filename_is_invalid=Le nom du fichier est invalide : "%s". +editor.branch_does_not_exist=La branche "%s" n'existe pas dans ce dépôt. +editor.branch_already_exists=La branche "%s" existe déjà dans ce dépôt. +editor.directory_is_a_file=Le nom de dossier "%s" est déjà utilisé comme nom de fichier dans ce dépôt. +editor.file_is_a_symlink=`"%s" est un lien symbolique. Les liens symboliques ne peuvent pas être édités dans l'éditeur web.` editor.file_changed_while_editing=Le contenu du fichier a changé depuis que vous avez commencé à éditer. Cliquez ici pour voir les changements ou soumettez de nouveau pour les écraser. +editor.file_already_exists=Un fichier nommé "%s" existe déjà dans ce dépôt. editor.commit_empty_file_header=Commiter un fichier vide editor.commit_empty_file_text=Le fichier que vous allez commiter est vide. Continuer ? -editor.no_changes_to_show=Il n’y a aucun changement à afficher. +editor.no_changes_to_show=Il n’y a aucune modification à afficher. +editor.fail_to_update_file=Impossible de mettre à jour/créer le fichier "%s". editor.fail_to_update_file_summary=Message d'erreur : editor.push_rejected_no_message=La modification a été rejetée par le serveur sans message. Veuillez vérifier les Git Hooks. editor.push_rejected=La modification a été rejetée par le serveur. Veuillez vérifier vos Git Hooks. editor.push_rejected_summary=Message de rejet complet : editor.add_subdir=Ajouter un dossier… +editor.unable_to_upload_files=Impossible d'envoyer le fichier "%s" : %v +editor.upload_file_is_locked=Le fichier "%s" est verrouillé par %s. +editor.upload_files_to_dir=`Téléverser les fichiers vers "%s"` +editor.cannot_commit_to_protected_branch=Impossible de créer une révision sur la branche protégée "%s". editor.no_commit_to_branch=Impossible d'enregistrer la révisions directement sur la branche parce que : editor.user_no_push_to_branch=L'utilisateur ne peut pas pousser vers la branche editor.require_signed_commit=Cette branche nécessite une révision signée @@ -1062,6 +1174,7 @@ editor.revert=Rétablir %s sur: commits.desc=Naviguer dans l'historique des modifications. commits.commits=Révisions +commits.no_commits=Pas de révisions en commun. "%s" et "%s" ont des historiques entièrement différents. commits.nothing_to_compare=Ces branches sont égales. commits.search=Rechercher des révisions… commits.search.tooltip=Vous pouvez préfixer les mots-clés avec "author:", "committer:", "after:", ou "before:", par exemple "revert author:Alice before:2019-01-13". @@ -1078,6 +1191,7 @@ commits.signed_by_untrusted_user_unmatched=Signé par un utilisateur non fiable commits.gpg_key_id=ID de la clé GPG commits.ssh_key_fingerprint=Empreinte numérique de la clé SSH +commit.operations=Opérations commit.revert=Rétablir commit.revert-header=Rétablir : %s commit.revert-content=Sélectionnez la branche sur laquelle revenir : @@ -1085,6 +1199,7 @@ commit.cherry-pick=Picorer commit.cherry-pick-header=Picorer : %s commit.cherry-pick-content=Sélectionner la branche à picorer : +ext_issues=Accès aux tickets externes ext_issues.desc=Lien vers un gestionnaire de tickets externe. projects=Projets @@ -1095,24 +1210,36 @@ projects.create=Créer un projet projects.title=Titre projects.new=Nouveau projet projects.new_subheader=Coordonnez, surveillez, et mettez à jour votre travail en un seul endroit, afin que les projets restent transparents et dans les délais. +projects.create_success=Le projet "%s" a été créé. projects.deletion=Supprimer le projet projects.deletion_desc=Supprimer un projet efface également de tous les tickets liés. Voulez vous continuer? projects.deletion_success=Le projet a été supprimé. projects.edit=Modifier les projets projects.edit_subheader=Les projets organisent les tickets et la progression. projects.modify=Modifier le projet +projects.edit_success=Le projet "%s" a été mis à jour. projects.type.none=Aucun projects.type.basic_kanban=Kanban basique projects.type.bug_triage=Bug à trier projects.template.desc=Modèle de projet projects.template.desc_helper=Sélectionnez un modèle de projet pour débuter projects.type.uncategorized=Non catégorisé +projects.column.edit=Modifier la colonne projects.column.edit_title=Nom projects.column.new_title=Nom +projects.column.new_submit=Créer une colonne projects.column.new=Nouvelle colonne +projects.column.set_default=Définir par défaut +projects.column.set_default_desc=Définir cette colonne par défaut pour les tickets et demande d'ajouts non catégorisés +projects.column.delete=Supprimer la colonne +projects.column.deletion_desc=La suppression d'une colonne de projet déplace tous les tickets liés à 'Non catégorisé'. Continuer ? projects.column.color=Couleur projects.open=Ouvrir projects.close=Fermer +projects.column.assigned_to=Assigné à +projects.card_type.desc=Aperçu de la carte +projects.card_type.images_and_text=Images et texte +projects.card_type.text_only=Texte uniquement issues.desc=Organiser les rapports de bug, les tâches et les jalons. issues.filter_assignees=Filtrer par assignation @@ -1149,6 +1276,8 @@ issues.choose.get_started=Démarrons issues.choose.open_external_link=Ouvrir issues.choose.blank=Par défaut issues.choose.blank_about=Créer un ticket à partir du modèle par défaut. +issues.choose.ignore_invalid_templates=Les modèles invalides ont été ignorés +issues.choose.invalid_config=La configuration du ticket contient des erreurs : issues.no_ref=Aucune branche/étiquette spécifiées issues.create=Créer un ticket issues.new_label=Nouvelle étiquette @@ -1177,7 +1306,8 @@ issues.self_assign_at=`s'est assigné cela %s` issues.add_assignee_at=`s'est vu assigner cela par %s %s` issues.remove_assignee_at=`mis en non assigné par %s %s` issues.remove_self_assignment=`a retiré son assignation %s` -issues.change_title_at=`a modifié le titre de %s à %s %s` +issues.change_title_at=`a modifié le titre %s pour %s %s` +issues.change_ref_at=`à modifiée la référence %s pour %s%s` issues.delete_branch_at=`a supprimé la branche %s %s` issues.filter_label=Étiquette issues.filter_label_exclude=`Utiliser alt + clic/entrée pour exclure les étiquettes` @@ -1185,6 +1315,7 @@ issues.filter_label_no_select=Toutes les étiquettes issues.filter_milestone=Jalon issues.filter_milestone_no_select=Tous les jalons issues.filter_project=Projet +issues.filter_project_all=Tous les projets issues.filter_project_none=Pas de projet issues.filter_assignee=Assigné issues.filter_assginee_no_select=Toutes les affectations @@ -1196,6 +1327,7 @@ issues.filter_type.assigned_to_you=Qui vous sont assignés issues.filter_type.created_by_you=Créés par vous issues.filter_type.mentioning_you=Vous mentionnant issues.filter_type.review_requested=Revue demandée +issues.filter_type.reviewed_by_you=Revu par vous issues.filter_sort=Trier issues.filter_sort.latest=Plus récent issues.filter_sort.oldest=Plus ancien/ne @@ -1209,6 +1341,7 @@ issues.filter_sort.moststars=Favoris (décroissant) issues.filter_sort.feweststars=Favoris (croissant) issues.filter_sort.mostforks=Bifurcations (décroissant) issues.filter_sort.fewestforks=Bifurcations (croissant) +issues.keyword_search_unavailable=La recherche par mot clé n'est pas disponible. Veuillez contacter l'administrateur de votre instance. issues.action_open=Ouvrir issues.action_close=Fermer issues.action_label=Étiquette @@ -1216,6 +1349,8 @@ issues.action_milestone=Jalon issues.action_milestone_no_select=Aucun jalon issues.action_assignee=Assigné à issues.action_assignee_no_select=Pas d'assignataire +issues.action_check=Cocher/Décocher +issues.action_check_all=Cocher/Décocher tous les éléments issues.opened_by=créé %[1]s par %[3]s issues.opened_by_fake=%[1]s ouvert par %[2]s issues.previous=Page Précédente @@ -1232,6 +1367,7 @@ issues.context.reference_issue=Référencer dans un nouveau ticket issues.context.edit=Éditer issues.context.delete=Supprimer issues.no_content=Il n'existe pas encore de contenu. +issues.close=Fermer le ticket issues.pull_merged_at=`révision fusionnée %[2]s dans %[3]s %[4]s` issues.manually_pull_merged_at=`révision fusionnée %[2]s dans %[3]s manuellement %[4]s` issues.close_comment_issue=Commenter et Fermer @@ -1264,6 +1400,7 @@ issues.save=Enregistrer issues.label_title=Nom de l'étiquette issues.label_description=Description de l’étiquette issues.label_color=Couleur de l'étiquette +issues.label_exclusive=Exclusif issues.label_exclusive_desc=Nommez le libellé périmètre/élément pour qu'il soit mutuellement exclusif avec d'autres libellés du périmètre. issues.label_exclusive_warning=Tout libellé conflictuel sera supprimé lors de l'édition des libellés d'un ticket ou d'une demande de tirage. issues.label_count=%d étiquettes @@ -1303,6 +1440,8 @@ issues.lock.title=Verrouiller la conversation sur ce ticket. issues.unlock.title=Déverrouiller la conversation sur ce ticket. issues.comment_on_locked=Vous ne pouvez pas commenter un ticket verrouillé. issues.delete=Supprimer +issues.delete.title=Supprimer ce ticket ? +issues.delete.text=Voulez-vous vraiment supprimer ce ticket ? (Cette opération supprimera définitivement tout le contenu. Envisagez plutôt de le fermer si vous avez l'intention de l'archiver) issues.tracker=Suivi du temps issues.start_tracking_short=Démarrer le suivi de temps issues.start_tracking=Démarrer le suivi du temps @@ -1339,10 +1478,16 @@ issues.due_date_form_remove=Supprimer issues.due_date_not_writer=Vous devez avoir accès au dépôt en écriture pour mettre à jour l'échéance d'un ticket. issues.due_date_not_set=Aucune échéance n'a été définie. issues.due_date_added=a ajouté l'échéance %s %s +issues.due_date_modified=a modifié l'échéance de %[2]s à %[1]s %[3]s issues.due_date_remove=a supprimé l'échéance %s %s issues.due_date_overdue=En retard issues.due_date_invalid=La date d’échéance est invalide ou hors plage. Veuillez utiliser le format 'aaaa-mm-dd'. issues.dependency.title=Dépendances +issues.dependency.issue_no_dependencies=Aucune dépendance définie. +issues.dependency.pr_no_dependencies=Aucune dépendance définie. +issues.dependency.no_permission_1=Vous n’avez pas la permission de voir la dépendance de %d +issues.dependency.no_permission_n=Vous n’avez pas la permission de voir les dépendances de %d +issues.dependency.no_permission.can_remove=Vous n'avez pas la permission de voir cette dépendance, mais vous pouvez la supprimer issues.dependency.add=Ajouter une dépendance… issues.dependency.cancel=Annuler issues.dependency.remove=Supprimer @@ -1381,6 +1526,7 @@ issues.review.add_review_request=a demandé une révision de %s %s issues.review.remove_review_request=a supprimé la demande de révision pour %s %s issues.review.remove_review_request_self=a refusé la revue %s issues.review.pending=En attente +issues.review.pending.tooltip=Ce commentaire n'est pas encore visible par les autres utilisateurs. Pour soumettre vos commentaires en attente, sélectionnez "%s" → "%s/%s/%s" en haut de la page. issues.review.review=Révision issues.review.reviewers=Relecteurs issues.review.outdated=Périmé @@ -1393,11 +1539,13 @@ issues.review.un_resolve_conversation=Conversation non résolue issues.review.resolved_by=marquer cette conversation comme résolue issues.assignee.error=Tous les assignés n'ont pas été ajoutés en raison d'une erreur inattendue. issues.reference_issue.body=Corps +issues.content_history.deleted=supprimé issues.content_history.edited=édité issues.content_history.created=créé issues.content_history.delete_from_history=Supprimé de l’historique issues.content_history.delete_from_history_confirm=Supprimer de l’historique ? issues.content_history.options=Options +issues.reference_link=Référence : %s compare.compare_base=base compare.compare_head=comparer @@ -1406,10 +1554,19 @@ pulls.desc=Activer les demandes de fusion et la revue de code. pulls.new=Nouvelle demande d'ajout pulls.view=Voir la demande d'ajout pulls.compare_changes=Nouvelle demande de fusion +pulls.allow_edits_from_maintainers=Autoriser les modifications des mainteneurs +pulls.allow_edits_from_maintainers_desc=Les utilisateurs ayant un accès en écriture à la branche de base peuvent également pousser vers cette branche +pulls.allow_edits_from_maintainers_err=La mise à jour à échoué pulls.compare_changes_desc=Sélectionnez la branche dans laquelle fusionner et la branche depuis laquelle tirer les modifications. +pulls.has_viewed_file=Consulté +pulls.has_changed_since_last_review=Modifiée depuis votre dernière revue +pulls.viewed_files_label=%[1]d / %[2]d fichiers vus +pulls.expand_files=Développer tous les fichiers +pulls.collapse_files=Réduire tous les fichiers pulls.compare_base=fusionner dans pulls.compare_compare=tirer les modifications depuis pulls.switch_comparison_type=Changer le type de comparaison +pulls.switch_head_and_base=Passez de head à base pulls.filter_branch=Filtre de branche pulls.no_results=Aucun résultat trouvé. pulls.nothing_to_compare=Ces branches sont identiques. Il n'y a pas besoin de créer une demande de fusion. @@ -1418,14 +1575,17 @@ pulls.has_pull_request='Il existe déjà une demande d'ajout entre ces deux bran pulls.create=Créer une demande d'ajout pulls.title_desc=veut fusionner %[1]d révision(s) depuis %[2]s vers %[3]s pulls.merged_title_desc=a fusionné %[1]d révision(s) à partir de %[2]s vers %[3]s %[4]s -pulls.change_target_branch_at=`a changé la branche cible de %s à %s %s` +pulls.change_target_branch_at=`a modifié la branche cible %s pour %s %s` pulls.tab_conversation=Discussion pulls.tab_commits=Révisions pulls.tab_files=Fichiers Modifiés pulls.reopen_to_merge=Veuillez rouvrir cette demande d'ajout pour effectuer l'opération de fusion. pulls.cant_reopen_deleted_branch=Cette demande d'ajout ne peut pas être rouverte car la branche a été supprimée. pulls.merged=Fusionnée +pulls.merged_success=Demande d’ajout fusionnée et fermée avec succès +pulls.closed=Demande d’ajout fermée pulls.manually_merged=Fusionné manuellement +pulls.merged_info_text=La branche %s peut maintenant être supprimée. pulls.is_closed=La demande de fusion a été fermée. pulls.title_wip_desc=`Préfixer le titre par %s pour empêcher cette demande d'ajout d'être fusionnée par erreur.` pulls.cannot_merge_work_in_progress=Cette demande d'ajout est marquée comme en cours de chantier. @@ -1435,6 +1595,7 @@ pulls.remove_prefix=Enlever le préfixe %s pulls.data_broken=Cette demande de fusion est impossible par manque d'informations de bifurcation. pulls.files_conflicted=Cette demande d'ajout contient des modifications en conflit avec la branche ciblée. pulls.is_checking=Vérification des conflits de fusion en cours. Réessayez dans quelques instants. +pulls.is_ancestor=Cette branche est déjà présente dans la branche ciblée. Il n'y a rien à fusionner. pulls.is_empty=Les changements sur cette branche sont déjà sur la branche cible. Cette révision sera vide. pulls.required_status_check_failed=Certains contrôles requis n'ont pas réussi. pulls.required_status_check_missing=Certains contrôles requis sont manquants. @@ -1488,18 +1649,24 @@ pulls.status_checks_failure=Certaines vérifications ont échoué pulls.status_checks_error=Quelques vérifications ont signalé des erreurs pulls.status_checks_requested=Requis pulls.status_checks_details=Détails +pulls.update_branch=Actualiser la branche par fusion +pulls.update_branch_rebase=Actualiser la branche par rebasage pulls.update_branch_success=La mise à jour de la branche a réussi pulls.update_not_allowed=Vous n'êtes pas autorisé à mettre à jour la branche pulls.outdated_with_base_branch=Cette branche est désynchronisée avec la branche de base +pulls.close=Fermer la demande d’ajout pulls.closed_at=`a fermé cette pull request %[2]s` pulls.reopened_at=`a réouvert cette pull request %[2]s` pulls.merge_instruction_hint=`Vous pouvez également voir les instructions en ligne de commande.` pulls.merge_instruction_step1_desc=Depuis le dépôt de votre projet, sélectionnez une nouvelle branche et testez les modifications. pulls.merge_instruction_step2_desc=Fusionner les modifications et mettre à jour sur Gitea. +pulls.clear_merge_message=Effacer le message de fusion +pulls.auto_merge_button_when_succeed=(Lorsque les vérifications ont réussi) pulls.auto_merge_newly_scheduled=La demande d'ajout était programmée pour fusionner lorsque toutes les vérifications aurait réussi. pulls.auto_merge_has_pending_schedule=%[1]s Ont planifié cette demande d'ajout pour fusionner automatiquement lorsque toutes les vérifications réussissent %[2]s. +pulls.auto_merge_cancel_schedule=Annuler la fusion automatique pulls.auto_merge_not_scheduled=Cette demande d'ajout n'est pas planifiée pour fusionner automatiquement. pulls.auto_merge_canceled_schedule=La fusion automatique a été annulée pour cette demande d'ajout. @@ -1511,6 +1678,7 @@ pulls.delete.text=Voulez-vous vraiment supprimer cet demande d'ajout ? (Cela sup milestones.new=Nouveau jalon milestones.closed=%s fermé +milestones.update_ago=Actualisé il y a %s milestones.no_due_date=Aucune date d'échéance milestones.open=Ouvrir milestones.close=Fermer @@ -1522,10 +1690,12 @@ milestones.desc=Description milestones.due_date=Date d'échéance (facultatif) milestones.clear=Effacer milestones.invalid_due_date_format=Le format de la date d'échéance est invalide, il doit être comme suit 'aaaa-mm-jj'. +milestones.create_success=Le jalon "%s" a été créé. milestones.edit=Éditer le Jalon milestones.edit_subheader=Les jalons organisent les tickets et le suivi d'avancement. milestones.cancel=Annuler milestones.modify=Mettre à jour un jalon +milestones.edit_success=Le jalon "%s" a été mis à jour. milestones.deletion=Supprimer un Jalon milestones.deletion_desc=Supprimer un jalon le retire de tous les tickets. Continuer ? milestones.deletion_success=Le jalon a été supprimé. @@ -1536,6 +1706,7 @@ milestones.filter_sort.most_complete=Le plus complété milestones.filter_sort.most_issues=Le plus de tickets milestones.filter_sort.least_issues=Le moins de tickets +signing.will_sign=`Cette révision sera signée avec la clé "%s"` signing.wont_sign.error=Une erreur s'est produite lors de la vérification de la signature de la révision signing.wont_sign.nokey=Il n'y a aucune clé disponible pour signer cette révision signing.wont_sign.never=Les révisions ne sont jamais signées @@ -1549,6 +1720,7 @@ signing.wont_sign.commitssigned=La fusion ne sera pas signée car toutes les ré signing.wont_sign.approved=La fusion ne sera pas signée car la PR n'a pas approuvée signing.wont_sign.not_signed_in=Vous n'êtes pas authentifié +ext_wiki=Accès au wiki externe ext_wiki.desc=Lier un wiki externe. wiki=Wiki @@ -1559,6 +1731,8 @@ wiki.create_first_page=Créer la première page wiki.page=Page wiki.filter_page=Filtrer la page wiki.new_page=Page +wiki.page_title=Titre de la page +wiki.page_content=Contenu de la page wiki.default_commit_message=Écrire une note concernant cette mise à jour (optionnel). wiki.save_page=Enregistrer la page wiki.last_commit_info=%s a édité cette page %s @@ -1568,7 +1742,9 @@ wiki.file_revision=Révisions de la page wiki.wiki_page_revisions=Révisions de la page wiki wiki.back_to_wiki=Retour à la page wiki wiki.delete_page_button=Supprimer la page +wiki.delete_page_notice_1=Supprimer la page de wiki "%s" est irréversible. Continuer ? wiki.page_already_exists=Une page de wiki avec le même nom existe déjà. +wiki.reserved_page=Le nom de page de wiki "%s" est réservé. wiki.pages=Pages wiki.last_updated=Dernière mise à jour: %s @@ -1699,20 +1875,28 @@ settings.pulls.ignore_whitespace=Ignorer les espaces lors des conflits settings.pulls.enable_autodetect_manual_merge=Activer la détection automatique de la fusion manuelle (Remarque : dans certains cas particuliers, des erreurs de détection peuvent se produire) settings.pulls.allow_rebase_update=Activer la mise à jour de demande d'ajout par rebase settings.pulls.default_delete_branch_after_merge=Supprimer la branche après la fusion par default +settings.packages_desc=Activer le registre des paquets du dépôt settings.projects_desc=Activer les projets de dépôt +settings.actions_desc=Activer les actions du dépôt settings.admin_settings=Paramètres administrateur settings.admin_enable_health_check=Activer les vérifications de santé du dépôt (git fsck) +settings.admin_code_indexer=Indexeur de code +settings.admin_stats_indexer=Indexeur des statistiques de code +settings.admin_indexer_commit_sha=Dernier SHA indexé +settings.admin_indexer_unindexed=Non indexé +settings.reindex_button=Ajouter à queue de réindexation +settings.reindex_requested=Réindexation demandée settings.admin_enable_close_issues_via_commit_in_any_branch=Fermer un ticket via une révision faite sur une branche non par défaut settings.danger_zone=Zone de danger settings.new_owner_has_same_repo=Le nouveau propriétaire a déjà un dépôt nommé ainsi. settings.convert=Convertir en dépôt standard -settings.convert_desc=Vous pouvez convertir ce miroir en dépôt standard. Ceci ne peut pas être annulé. -settings.convert_notices_1=Cette opération convertira le miroir en dépôt standard et ne peut être annulée. +settings.convert_desc=Vous pouvez convertir ce miroir en dépôt standard. Cette action est irréversible. +settings.convert_notices_1=Cette opération convertira le miroir en dépôt standard. Cette action est irréversible. settings.convert_confirm=Convertir le dépôt settings.convert_succeed=Le miroir a été converti en dépôt standard. settings.convert_fork=Convertir en dépôt standard -settings.convert_fork_desc=Vous pouvez convertir ce miroir en dépôt standard. Ceci ne peut pas être annulé. -settings.convert_fork_notices_1=Cette opération convertira le miroir en dépôt standard et ne peut être annulée. +settings.convert_fork_desc=Vous pouvez convertir ce miroir en dépôt standard. Cette action est irréversible. +settings.convert_fork_notices_1=Cette opération convertira le miroir en dépôt standard. Cette action est irréversible. settings.convert_fork_confirm=Convertir le dépôt settings.convert_fork_succeed=Le miroir a été converti en dépôt standard. settings.transfer=Changer de propriétaire @@ -1744,13 +1928,13 @@ settings.trust_model.collaboratorcommitter=Collaborateur+Committer settings.trust_model.collaboratorcommitter.long=Collaborateur+Committer: Faire confiance aux signatures des collaborateurs qui correspondent à l'auteur settings.trust_model.collaboratorcommitter.desc=Les signatures valides des des collaborateurs de ce dépôt seront marquées "de confiance" si elles correspondent à l'expéditeur. Dans le cas contraire, les signatures valides seront marquées "non fiables" si la signature correspond au validateur et "sans correspondance" pour les autres cas. Cela forcera Gitea à être marqué comme le committer sur les commits signés avec le committer réel marqué comme Co-Authored-By: et Co-Committed-By: inclus dans la livraison. La clé par défaut de Gitea doit correspondre à un utilisateur dans la base de données. settings.wiki_delete=Supprimer les données du Wiki -settings.wiki_delete_desc=Supprimer les données du wiki d'un dépôt est permanent et ne peut être annulé. +settings.wiki_delete_desc=Supprimer les données du wiki d'un dépôt est permanent. Cette action est irréversible. settings.wiki_delete_notices_1=- Ceci supprimera de manière permanente et désactivera le wiki de dépôt pour %s. settings.confirm_wiki_delete=Supprimer les données du Wiki settings.wiki_deletion_success=Les données du wiki de ce dépôt ont été effacées. settings.delete=Supprimer ce dépôt -settings.delete_desc=Supprimer un dépôt est permanent et ne peut être annulé. -settings.delete_notices_1=- Cette opération ne peut pas être annulée. +settings.delete_desc=Supprimer un dépôt est permanent et irréversible. +settings.delete_notices_1=- Cette opération est irréversible. settings.delete_notices_2=- Cette opération supprimera définitivement le dépôt %s, y compris le code, les tickets, les commentaires, les données de wiki et les accès des collaborateurs. settings.delete_notices_fork_1=- Les bifurcations de ce dépôt deviendront indépendants après suppression. settings.deletion_success=Le dépôt a été supprimé. @@ -1789,6 +1973,7 @@ settings.webhook.response=Réponse settings.webhook.headers=Entêtes settings.webhook.payload=Contenu settings.webhook.body=Corps +settings.webhook.replay.description=Rejouer ce déclencheur. settings.githook_edit_desc=Si un Hook est inactif, un exemple de contenu vous sera proposé. Un contenu laissé vide signifie un Hook inactif. settings.githook_name=Nom du Hook settings.githook_content=Contenu du Hook @@ -1847,19 +2032,39 @@ settings.event_pull_request_review=Demande d'ajout révisée settings.event_pull_request_review_desc=Demande d'ajout approvée, rejetée ou commentaire de révision. settings.event_pull_request_sync=Demande d'ajout synchronisée settings.event_pull_request_sync_desc=Demande d'ajout synchronisée. +settings.event_package=Paquet settings.branch_filter=Filtre de branche settings.branch_filter_desc=Liste blanche pour les poussées, la création et la suppression de branches, spécifiées par motif de glob. Si vide ou *, les événements pour toutes les branches sont signalés. Voir la documentation github.com/gobwas/glob pour la syntaxe. Exemples: master, {master,release*}. +settings.authorization_header=En-tête « Authorization » +settings.authorization_header_desc=Si présent, sera ajouté aux requêtes comme en-tête d’authentification. Exemples : %s. settings.active=Actif settings.active_helper=Les informations sur les événements déclenchés seront envoyées à cette url de Webhook. settings.add_hook_success=Nouveau Webhook ajouté. -settings.update_webhook=Mettre à jour le Webhook -settings.update_hook_success=Webhook mis à jour. +settings.update_webhook=Actualiser le déclencheur +settings.update_hook_success=Déclencheur Web actualisé. settings.delete_webhook=Retirer le Webhook settings.recent_deliveries=Livraisons récentes settings.hook_type=Type de Hook settings.slack_token=Jeton settings.slack_domain=Domaine settings.slack_channel=Canal +settings.add_web_hook_desc=Intégrez %s dans votre dépôt. +settings.web_hook_name_gitea=Gitea +settings.web_hook_name_gogs=Gogs +settings.web_hook_name_slack=Slack +settings.web_hook_name_discord=Discord +settings.web_hook_name_dingtalk=DingTalk +settings.web_hook_name_telegram=Telegram +settings.web_hook_name_matrix=Matrix +settings.web_hook_name_msteams=Microsoft Teams +settings.web_hook_name_feishu_or_larksuite=Feishu / Lark Suite +settings.web_hook_name_feishu=Feishu +settings.web_hook_name_larksuite=Lark Suite +settings.web_hook_name_wechatwork=WeCom (Wechat Work) +settings.web_hook_name_packagist=Packagist +settings.packagist_username=Nom d'utilisateur Packagist +settings.packagist_api_token=Jeton API +settings.packagist_package_url=URL du paquet Packagist settings.deploy_keys=Clés de déploiement settings.add_deploy_key=Ajouter une clé de déploiement settings.deploy_key_desc=Les clefs de déploiement ont un accès en lecture seule au dépôt. @@ -1870,11 +2075,14 @@ settings.title=Titre settings.deploy_key_content=Contenu settings.key_been_used=Une clef de déploiement identique est déjà en cours d'utilisation. settings.key_name_used=Une clef de déploiement du même nom existe déjà. +settings.add_key_success=La clé de déploiement "%s" a été ajoutée. settings.deploy_key_deletion=Supprimer une clef de déploiement settings.deploy_key_deletion_desc=La suppression d'une clef de déploiement révoque son accès à ce dépôt. Continuer ? settings.deploy_key_deletion_success=La clé de déploiement a été supprimée. settings.branches=Branches settings.protected_branch=Protection de branche +settings.protected_branch.save_rule=Enregistrer la règle +settings.protected_branch.delete_rule=Supprimer la règle settings.protected_branch_can_push=Autoriser la poussée ? settings.protected_branch_can_push_yes=Vous pouvez pousser settings.protected_branch_can_push_no=Vous ne pouvez pas pousser @@ -1909,8 +2117,16 @@ settings.dismiss_stale_approvals=Rejeter les approbations obsolètes settings.dismiss_stale_approvals_desc=Quand de nouvelles révisions qui changent le contenu de la demande d'ajout sont poussées vers la branche, les anciennes approbations seront rejetées. settings.require_signed_commits=Exiger des révisions signées settings.require_signed_commits_desc=Rejeter les pushs vers cette branche s’ils ne sont pas signés ou vérifiables. +settings.protect_branch_name_pattern=Motif de nom de branche protégé +settings.protect_protected_file_patterns=Motifs de fichiers protégés (séparés par un point-virgule ';') : +settings.protect_protected_file_patterns_desc=Les fichiers protégés ne sont pas autorisés à être modifiés directement même si l'utilisateur a les droits pour ajouter, modifier ou supprimer des fichiers dans cette branche. Plusieurs motifs peuvent être séparés en utilisant un point-virgule (';'). Voir la documentation github.com/gobwas/glob pour la syntaxe du motif. Exemples: .drone.yml, /docs/**/*.txt. +settings.protect_unprotected_file_patterns=Motifs de fichiers non protégés (séparés par un point-virgule ’;’) : +settings.protect_unprotected_file_patterns_desc=Fichiers non protégés qui sont autorisés à être modifiés directement si l'utilisateur a des droits d’écriture, contournant ainsi les restrictions de poussée. Plusieurs motifs peuvent être séparés en utilisant un point-virgule (';'). Voir la documentation github.com/gobwas/glob pour la syntaxe des motifs. Exemples: .drone.yml, /docs/**/*.txt. settings.add_protected_branch=Activer la protection settings.delete_protected_branch=Désactiver la protection +settings.update_protect_branch_success=La règle de protection de branche "%s" a été mise à jour. +settings.remove_protected_branch_success=La règle de protection de branche "%s" a été retirée. +settings.remove_protected_branch_failed=Impossible de retirer la règle de protection de branche "%s". settings.protected_branch_deletion=Désactiver la protection de branche settings.protected_branch_deletion_desc=Désactiver la protection de branche permet aux utilisateurs ayant accès en écriture de pousser des modifications sur la branche. Continuer ? settings.block_rejected_reviews=Bloquer la fusion quand il y a des avis de rejet @@ -1920,14 +2136,21 @@ settings.block_on_official_review_requests_desc=La fusion ne sera pas possible q settings.block_outdated_branch=Bloquer la fusion si la demande d'ajout est obsolète settings.block_outdated_branch_desc=La fusion ne sera pas possible lorsque la branche principale est derrière la branche de base. settings.default_branch_desc=Sélectionnez une branche par défaut pour les demandes de fusion et les révisions : +settings.merge_style_desc=Fusionner les styles settings.default_merge_style_desc=Style de fusion par défaut pour les demandes d'ajouts : settings.choose_branch=Choisissez une branche… settings.no_protected_branch=Il n'y a pas de branche protégée. settings.edit_protected_branch=Éditer +settings.protected_branch_required_rule_name=Nom de la règle requise +settings.protected_branch_duplicate_rule_name=Nom de la règle en double settings.protected_branch_required_approvals_min=Le nombre de revues nécessaires ne peut être négatif. settings.tags=Étiquettes settings.tags.protection=Protection d'étiquette settings.tags.protection.pattern=Motif d'étiquette +settings.tags.protection.allowed=Autorisé +settings.tags.protection.allowed.users=Utilisateurs autorisés +settings.tags.protection.allowed.teams=Équipes autorisées +settings.tags.protection.allowed.noone=Personne settings.tags.protection.create=Protéger l'étiquette settings.tags.protection.none=Il n'y a pas d'étiquettes protégées. settings.tags.protection.pattern.description=Vous pouvez utiliser soit un nom unique, soit un motif de glob ou une expression régulière qui correspondront à plusieurs étiquettes. Pour plus d'informations, veuillez vous reporter au guide sur les étiquettes protégées. @@ -1975,6 +2198,12 @@ settings.lfs_pointers.inRepo=Dans le dépôt settings.lfs_pointers.exists=Existe en magasin settings.lfs_pointers.accessible=Accessible à l'utilisateur settings.lfs_pointers.associateAccessible=Associer %d OID accessibles +settings.rename_branch_failed_exist=Impossible de renommer la branche car la branche %s existe déjà. +settings.rename_branch_failed_not_exist=Impossible de renommer la branche %s car elle n’existe pas. +settings.rename_branch_success=La branche %s à été renommée avec succès en %s. +settings.rename_branch_from=ancien nom de la branche +settings.rename_branch_to=nouveau nom de la branche +settings.rename_branch=Renommer la branche diff.browse_source=Parcourir la source diff.parent=Parent @@ -1991,7 +2220,7 @@ diff.whitespace_button=Espace diff.whitespace_show_everything=Afficher toutes les modifications diff.whitespace_ignore_all_whitespace=Ignorer les espaces lors de la comparaison des lignes diff.whitespace_ignore_amount_changes=Ignorer les changements quand ce sont des espaces -diff.whitespace_ignore_at_eol=Ignorer les changements quand ce sont des espaces à la fin de la ligne +diff.whitespace_ignore_at_eol=Ignorer les blancs en fin de ligne diff.stats_desc= %d fichiers modifiés avec %d ajouts et %d suppressions diff.stats_desc_file=%d modifications: %d ajouts et %d suppressions diff.bin=BIN @@ -2004,7 +2233,11 @@ diff.file_image_height=Hauteur diff.file_byte_size=Taille diff.file_suppressed=Fichier diff supprimé car celui-ci est trop grand diff.file_suppressed_line_too_long=Diff de fichier supprimé car une ou plusieurs lignes sont trop longues +diff.too_many_files=Certains fichiers ne sont pas affichés car ce diff contient trop de modifications +diff.show_more=Voir plus +diff.load=Voir la Diff diff.generated=générée +diff.vendored=externe diff.comment.placeholder=Laisser un commentaire diff.comment.markdown_info=Mise en page avec markdown est prise en charge. diff.comment.add_single_comment=Ajouter un commentaire @@ -2016,12 +2249,17 @@ diff.review.header=Soumettre une révision diff.review.placeholder=Commentaire de révision diff.review.comment=Commenter diff.review.approve=Approuver +diff.review.self_reject=Les auteurs d’une demande d’ajout ne peuvent pas demander des changements sur leur propre demande d’ajout diff.review.reject=Demander des changements +diff.review.self_approve=Les auteurs d’une demande d’ajout ne peuvent pas approuver leur propre demande d’ajout diff.committed_by=commité par diff.protected=Protégé diff.image.side_by_side=Côte à côte diff.image.swipe=Glisser diff.image.overlay=Superposition +diff.has_escaped=Cette ligne contient des caractères Unicode cachés +diff.show_file_tree=Afficher l’arborescence des fichiers +diff.hide_file_tree=Masquer l’arborescence des fichiers releases.desc=Suivi des versions et des téléchargements. release.releases=Versions @@ -2044,12 +2282,13 @@ release.target=Cible release.tag_helper=Choisissez une étiquette existante ou créez une nouvelle étiquette. release.tag_helper_new=Nouvelle étiquette. Cette étiquette sera créée à partir de la cible. release.tag_helper_existing=Étiquette existante. +release.title_empty=Le titre ne peut pas être vide. release.prerelease_desc=Marquer comme pré-version release.prerelease_helper=Marquer cette version comme impropre à la production. release.cancel=Annuler release.publish=Publier release.save_draft=Sauvegarder le Brouillon -release.edit_release=Modifier la version +release.edit_release=Actualiser la version release.delete_release=Supprimer cette version release.delete_tag=Supprimer l'étiquette release.deletion=Supprimer cette version @@ -2069,13 +2308,25 @@ release.tags_for=Étiquettes pour %s branch.name=Nom de la branche branch.search=Rechercher des branches +branch.already_exists=Une branche nommée "%s" existe déjà. branch.delete_head=Supprimer +branch.delete=`Supprimer la branche "%s"` branch.delete_html=Supprimer la branche -branch.delete_desc=Supprimer une branche est permanent. Cela NE PEUVENT être annulées. Continuer ? +branch.delete_desc=Supprimer une branche est permanent. Cette action est IRRÉVERSIBLE. Continuer ? +branch.deletion_success=La branche "%s" a été supprimée. +branch.deletion_failed=Impossible de supprimer la branche "%s". +branch.delete_branch_has_new_commits=La branche "%s" ne peut être supprimé, car de nouvelles révisions ont été ajoutées après la fusion. branch.create_branch=Créer la branche %s branch.create_from=`de "%s"` +branch.create_success=La branche "%s" a été créée. +branch.branch_already_exists=La branche "%s" existe déjà dans ce dépôt. +branch.branch_name_conflict=Le nom de la branche "%s" entre en conflit avec la branche déjà existante "%s". branch.tag_collision=La branche "%s" ne peut être créée car une étiquette avec un nom identique existe déjà dans le dépôt. branch.deleted_by=Supprimée par %s +branch.restore_success=La branche "%s" a été restaurée. +branch.restore_failed=Impossible de restaurer la branche "%s". +branch.protected_deletion_failed=La branche "%s" est protégé. Elle ne peut pas être supprimée. +branch.default_deletion_failed=La branche "%s" est la branche par défaut. Elle ne peut pas être supprimée. branch.restore=`Restaurer la branche "%s"` branch.download=`Télécharger la branche "%s"` branch.included_desc=Cette branche fait partie de la branche par défaut @@ -2111,7 +2362,7 @@ org_name_holder=Nom de l'organisation org_full_name_holder=Nom complet de l'organisation org_name_helper=Le nom de l'organisation doit être court et mémorable. create_org=Créer une organisation -repo_updated=Mis à jour +repo_updated=Actualisé members=Membres teams=Équipes code=Code @@ -2129,6 +2380,7 @@ team_permission_desc=Autorisation team_unit_desc=Permettre l’accès aux Sections du dépôt team_unit_disabled=(Désactivé) +form.name_reserved=Le nom d'organisation "%s" est réservé. form.name_pattern_not_allowed=Le motif "%s" n'est pas autorisé dans un nom d'organisation. form.create_org_not_allowed=Vous n'êtes pas autorisé à créer une organisation. @@ -2146,14 +2398,14 @@ settings.visibility.limited_shortname=Limité settings.visibility.private=Privé (Visible uniquement aux membres de l’organisation) settings.visibility.private_shortname=Privé -settings.update_settings=Valider +settings.update_settings=Appliquer les paramètres settings.update_setting_success=Les paramètres de l'organisation ont été mis à jour. settings.change_orgname_prompt=NB: changer le nom de l'organisation changera aussi son URL. settings.change_orgname_redirect_prompt=L'ancien nom d'utilisateur redirigera jusqu'à ce qu'il soit réclamé. settings.update_avatar_success=L'avatar de l'organisation a été mis à jour. settings.delete=Supprimer l'organisation settings.delete_account=Supprimer cette organisation -settings.delete_prompt=Cette organisation sera supprimée définitivement. Cette opération est IRRÉVERSIBLE ! +settings.delete_prompt=Cette organisation sera supprimée définitivement. Cette action est IRRÉVERSIBLE ! settings.confirm_delete_account=Confirmez la suppression settings.delete_org_title=Supprimer l'organisation settings.delete_org_desc=Cette organisation sera supprimée définitivement. Voulez-vous continuer ? @@ -2195,7 +2447,7 @@ teams.no_desc=Aucune description teams.settings=Paramètres teams.owners_permission_desc=Les propriétaires ont un accès complet à tous les dépôts et disposent d'un accès administrateur de l'organisation. teams.members=Membres de L'Équipe -teams.update_settings=Valider +teams.update_settings=Appliquer les paramètres teams.delete_team=Supprimer l'équipe teams.add_team_member=Ajouter un Membre teams.invite_team_member=Inviter à %s @@ -2270,7 +2522,7 @@ dashboard.delete_repo_archives.started=Tâche de suppression de toutes les archi dashboard.delete_missing_repos=Supprimer tous les dépôts dont les fichiers Git sont manquants dashboard.delete_missing_repos.started=Tâche de suppression de tous les dépôts sans fichiers Git démarrée. dashboard.delete_generated_repository_avatars=Supprimer les avatars de dépôt générés -dashboard.update_mirrors=Mettre à jour les miroirs +dashboard.update_mirrors=Actualiser les miroirs dashboard.repo_health_check=Vérifier l'état de santé de tous les dépôts dashboard.check_repo_stats=Voir les statistiques de tous les dépôts dashboard.archive_cleanup=Supprimer les archives des vieux dépôts @@ -2364,8 +2616,15 @@ users.reset_2fa=Réinitialiser l'authentification à deux facteurs users.list_status_filter.menu_text=Filtrer users.list_status_filter.reset=Réinitialiser users.list_status_filter.is_active=Actif +users.list_status_filter.not_active=Inactif users.list_status_filter.is_admin=Administrateur +users.list_status_filter.not_admin=Non Administrateur users.list_status_filter.is_restricted=Restreint +users.list_status_filter.not_restricted=Non restraint +users.list_status_filter.is_prohibit_login=Interdit de connexion +users.list_status_filter.not_prohibit_login=Autorisé à se connecter +users.list_status_filter.is_2fa_enabled=2FA Activé +users.list_status_filter.not_2fa_enabled=2FA désactivé emails.email_manage_panel=Gestion des courriels des utilisateurs emails.primary=Principale @@ -2397,13 +2656,22 @@ repos.forks=Bifurcations repos.issues=Tickets repos.size=Taille +packages.package_manage_panel=Gestion des paquets +packages.total_size=Taille totale : %s +packages.unreferenced_size=Taille non référencée : %s packages.owner=Propriétaire +packages.creator=Créateur packages.name=Nom +packages.version=Version packages.type=Type packages.repository=Dépôt packages.size=Taille packages.published=Publiés +defaulthooks=Déclencheurs web par défaut +defaulthooks.desc=Les Déclencheurs Web font des requêtes HTTP POST à un serveur lorsque certains événements Gitea se produisent. Les Déclencheurs déclarés ici seront prédéfinit dans tous nouveaux dépôts. Consultez le guide sur les Déclencheurs Web. +defaulthooks.add_webhook=Ajouter un déclencheur web par défaut +defaulthooks.update_webhook=Mettre à jour le déclencheur web par défaut systemhooks=Rappels système systemhooks.desc=Les Webhooks font automatiquement des requêtes HTTP POST à un serveur lorsque certains événements Gitea se déclenchent. Les Webhooks définis ici agiront sur tous les dépots du système, donc veuillez prendre en compte les implications en termes de performances que cela peut avoir. Lire la suite dans le guide des Webhooks. @@ -2433,6 +2701,7 @@ auths.attribute_name=Attribut prénom auths.attribute_surname=Attribut nom de famille auths.attribute_mail=Attribut e-mail auths.attribute_ssh_public_key=Attribut clef SSH publique +auths.attribute_avatar=Attribut de l'avatar auths.attributes_in_bind=Aller chercher les attributs dans le contexte de liaison DN auths.allow_deactivate_all=Permettre à un résultat de recherche vide de désactiver tous les utilisateurs auths.use_paged_search=Utiliser la recherche paginée @@ -2441,6 +2710,8 @@ auths.filter=Filtre utilisateur auths.admin_filter=Filtre administrateur auths.restricted_filter=Filtre restrictif auths.restricted_filter_helper=Laisser vide pour ne définir aucun utilisateur comme restreint. Utilisez un astérisque ('*') pour définir tous les utilisateurs qui ne correspondent pas au filtre Admin comme restreint. +auths.map_group_to_team_removal=Retirer les utilisateurs des équipes synchronisées si l'utilisateur n'appartient pas au groupe LDAP correspondant +auths.enable_ldap_groups=Activer les groupes LDAP auths.ms_ad_sa=Rechercher les attributs MS AD auths.smtp_auth=Type d'authentification SMTP auths.smtphost=Hôte SMTP @@ -2449,9 +2720,14 @@ auths.allowed_domains=Domaines autorisés auths.allowed_domains_helper=Laisser ce champ vide autorise tous les domaines. Separez les domaines multiples avec une virgule (","). auths.skip_tls_verify=Ne pas vérifier TLS auths.force_smtps=Forcer SMTPS +auths.force_smtps_helper=SMTPS est toujours utilisé sur le port 465. Définissez ceci pour forcer SMTPS sur d'autres ports. (STARTTLS sera utilisé sur d'autres ports si cela est supporté par l'hôte.) auths.helo_hostname=Nom d’hôte HELO +auths.helo_hostname_helper=Nom d’hôte envoyé avec HELO. Laisser vide pour envoyer le nom d’hôte actuel. +auths.disable_helo=Désactiver HELO auths.pam_service_name=Nom du Service PAM +auths.pam_email_domain=Domaine de messagerie PAM (optionnel) auths.oauth2_provider=Fournisseur OAuth2 +auths.oauth2_icon_url=URL de l'icône auths.oauth2_clientID=ID du client (clé) auths.oauth2_clientSecret=Secret du client auths.openIdConnectAutoDiscoveryURL=URL de découverte OpenID Connect @@ -2460,6 +2736,10 @@ auths.oauth2_tokenURL=URL du jeton auths.oauth2_authURL=URL d'autorisation auths.oauth2_profileURL=URL du profil auths.oauth2_emailURL=URL de l'e-mail +auths.skip_local_two_fa=Ignorer l’authentification à deux facteurs locale +auths.skip_local_two_fa_helper=Laisser indéfini signifie que les utilisateurs locaux avec l’authentification à deux facteurs activée devront tout de même s’y soumettre pour se connecter +auths.oauth2_tenant=Locataire +auths.oauth2_required_claim_name_helper=Définissez ce nom pour restreindre la connexion depuis cette source aux utilisateurs ayant une réclamation avec ce nom auths.enable_auto_register=Connexion Automatique auths.sspi_auto_create_users=Créer automatiquement des utilisateurs auths.sspi_auto_create_users_helper=Autoriser la méthode d'authentification SSPI à créer automatiquement de nouveaux comptes pour les utilisateurs qui se connectent pour la première fois @@ -2886,6 +3166,8 @@ maven.documentation=Pour plus d'informations sur le registre Maven, voir la documentation. nuget.dependency.framework=Cadriciel cible +npm.registry=Configurez ce registre dans le fichier .npmrc de votre projet : +npm.install=Pour installer le paquet en utilisant npm, exécutez la commande suivante : npm.install2=ou ajoutez-le au fichier package.json : npm.documentation=Pour plus d'informations sur le registre npm, voir la documentation. npm.dependencies=Dépendances @@ -2893,9 +3175,12 @@ npm.dependencies.development=Dépendances de développement npm.dependencies.peer=Dépendances de pairs npm.dependencies.optional=Dépendances optionnelles npm.details.tag=Balise +pub.install=Pour installer le paquet en utilisant Dart, exécutez la commande suivante : pub.documentation=Pour davantage d'informations sur le registre Pub, voir la documentation. pypi.requires=Nécessite Python +pypi.install=Pour installer le paquet en utilisant pip, exécutez la commande suivante : pypi.documentation=Pour plus d'informations sur le registre PyPI, voir la documentation. +rubygems.install=Pour installer le paquet en utilisant gem, exécutez la commande suivante : rubygems.install2=ou ajoutez-le au Gemfile : rubygems.dependencies.runtime=Dépendances d'exécution rubygems.dependencies.development=Dépendances de développement @@ -2906,13 +3191,17 @@ swift.registry=Configurez ce registre à partir d'un terminal : swift.install=Ajoutez le paquet dans votre fichier Package.swift: swift.install2=et exécutez la commande suivante : swift.documentation=Pour plus d'informations sur le registre Swift, voir la documentation. +vagrant.install=Pour ajouter une machine Vagrant, exécutez la commande suivante : vagrant.documentation=Pour plus d'informations sur le registre Vagrant, voir la documentation. settings.link=Lier ce paquet à un dépôt +settings.link.description=Si vous liez un paquet à dépôt, le paquet sera inclus dans sa liste des paquets. settings.link.select=Sélectionner un dépôt settings.link.button=Actualiser le lien du dépôt settings.link.success=Le lien du dépôt a été mis à jour avec succès. settings.link.error=Impossible de mettre à jour le lien du dépôt. settings.delete=Supprimer le paquet +settings.delete.description=Supprimer un paquet est permanent et irréversible. +settings.delete.notice=Vous êtes sur le point de supprimer %s (%s). Cette opération est irréversible, êtes-vous sûr ? settings.delete.success=Le paquet a été supprimé. settings.delete.error=Impossible de supprimer le paquet. owner.settings.cargo.title=Index du Registre Cargo @@ -2956,10 +3245,11 @@ value=Valeur name=Nom creation=Ajouter un secret creation.name_placeholder=Caractères alphanumériques ou tirets bas uniquement, insensibles à la casse, ne peut commencer par GITEA_ ou GITHUB_ +creation.value_placeholder=Entrez n'importe quoi. Les blancs cernant seront taillés. creation.success=Le secret "%s" a été ajouté. creation.failed=Impossible d'ajouter le secret. deletion=Supprimer le secret -deletion.description=La suppression d'un secret est permanente et ne peut pas être annulée. Continuer ? +deletion.description=La suppression d'un secret est permanente et irréversible. Continuer ? deletion.success=Le secret a été supprimé. deletion.failed=Impossible de supprimer le secret. @@ -2990,7 +3280,7 @@ runners.labels=Étiquettes runners.last_online=Dernière fois en ligne runners.agent_labels=Étiquettes de l'agent runners.custom_labels=Étiquettes personnalisées -runners.custom_labels_helper=Les libellés personnalisées sont des libellés ajoutées manuellement par un administrateur. Ils sont séparés par des virgules et les blancs les cernant seront taillés. +runners.custom_labels_helper=Les libellés personnalisés sont des libellés ajoutés manuellement par un administrateur. Ils sont séparés par des virgules et les blancs les cernant seront taillés. runners.runner_title=Exécuteur runners.task_list=Tâches récentes sur cet exécuteur runners.task_list.run=Exécuter @@ -2999,9 +3289,9 @@ runners.task_list.repository=Dépôt runners.task_list.commit=Commit runners.task_list.done_at=Fait à runners.edit_runner=Éditer l'Exécuteur -runners.update_runner=Mettre à jour les modifications +runners.update_runner=Appliquer les modifications runners.update_runner_success=Exécuteur mis à jour avec succès -runners.update_runner_failed=Impossible de mettre à jour l'Exécuteur +runners.update_runner_failed=Impossible d'actualiser l'Exécuteur runners.delete_runner=Supprimer cet exécuteur runners.delete_runner_success=Exécuteur supprimé avec succès runners.delete_runner_failed=Impossible de supprimer l'Exécuteur From 6b33152b7dc81b38e5832a30c52cfad1902e86d0 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sun, 21 May 2023 09:50:53 +0800 Subject: [PATCH 14/27] Decouple the different contexts from each other (#24786) Replace #16455 Close #21803 Mixing different Gitea contexts together causes some problems: 1. Unable to respond proper content when error occurs, eg: Web should respond HTML while API should respond JSON 2. Unclear dependency, eg: it's unclear when Context is used in APIContext, which fields should be initialized, which methods are necessary. To make things clear, this PR introduces a Base context, it only provides basic Req/Resp/Data features. This PR mainly moves code. There are still many legacy problems and TODOs in code, leave unrelated changes to future PRs. --- modules/context/api.go | 115 ++++++--- modules/context/base.go | 300 ++++++++++++++++++++++ modules/context/context.go | 137 +++++----- modules/context/context_data.go | 43 ---- modules/context/context_form.go | 72 ------ modules/context/context_request.go | 27 -- modules/context/context_response.go | 102 +------- modules/context/context_serve.go | 23 -- modules/context/org.go | 2 +- modules/context/package.go | 77 +++--- modules/context/private.go | 29 +-- modules/context/repo.go | 41 +-- modules/context/response.go | 13 +- modules/context/utils.go | 4 +- modules/test/context_tests.go | 127 +++++---- modules/translation/translation.go | 14 +- modules/web/handler.go | 15 +- routers/api/actions/artifacts.go | 99 +++---- routers/api/packages/api.go | 4 +- routers/api/v1/api.go | 16 +- routers/api/v1/misc/markup.go | 4 +- routers/api/v1/misc/markup_test.go | 33 +-- routers/api/v1/notify/notifications.go | 2 +- routers/api/v1/repo/file.go | 12 +- routers/api/v1/repo/hook_test.go | 5 +- routers/api/v1/repo/issue.go | 4 +- routers/api/v1/repo/issue_comment.go | 6 +- routers/api/v1/repo/issue_tracked_time.go | 6 +- routers/api/v1/repo/migrate.go | 2 +- routers/api/v1/repo/repo_test.go | 15 +- routers/api/v1/repo/status.go | 2 +- routers/api/v1/user/helper.go | 2 +- routers/api/v1/utils/git.go | 11 +- routers/common/markup.go | 8 +- routers/common/middleware.go | 2 +- routers/common/serve.go | 8 +- routers/init.go | 2 +- routers/install/install.go | 19 +- routers/web/misc/markup.go | 10 +- routers/web/repo/attachment.go | 2 +- routers/web/repo/download.go | 10 +- routers/web/repo/http.go | 2 +- routers/web/repo/issue.go | 4 +- routers/web/repo/wiki.go | 2 +- services/auth/middleware.go | 52 ++-- services/context/user.go | 16 +- services/forms/admin.go | 6 +- services/forms/auth_form.go | 2 +- services/forms/org.go | 6 +- services/forms/package_form.go | 2 +- services/forms/repo_branch_form.go | 4 +- services/forms/repo_form.go | 72 +++--- services/forms/repo_tag_form.go | 2 +- services/forms/runner.go | 2 +- services/forms/user_form.go | 48 ++-- services/forms/user_form_auth_openid.go | 6 +- services/markup/processorhelper_test.go | 9 +- 57 files changed, 882 insertions(+), 778 deletions(-) create mode 100644 modules/context/base.go delete mode 100644 modules/context/context_data.go delete mode 100644 modules/context/context_form.go delete mode 100644 modules/context/context_serve.go diff --git a/modules/context/api.go b/modules/context/api.go index e263dcbe8dea1..092ad73f31118 100644 --- a/modules/context/api.go +++ b/modules/context/api.go @@ -13,18 +13,32 @@ import ( "code.gitea.io/gitea/models/auth" repo_model "code.gitea.io/gitea/models/repo" - "code.gitea.io/gitea/modules/cache" + "code.gitea.io/gitea/models/unit" + user_model "code.gitea.io/gitea/models/user" + mc "code.gitea.io/gitea/modules/cache" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/httpcache" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/web/middleware" + + "gitea.com/go-chi/cache" ) // APIContext is a specific context for API service type APIContext struct { - *Context - Org *APIOrganization + *Base + + Cache cache.Cache + + Doer *user_model.User // current signed-in user + IsSigned bool + IsBasicAuth bool + + ContextUser *user_model.User // the user which is being visited, in most cases it differs from Doer + + Repo *Repository + Org *APIOrganization + Package *Package } // Currently, we have the following common fields in error response: @@ -128,11 +142,6 @@ type apiContextKeyType struct{} var apiContextKey = apiContextKeyType{} -// WithAPIContext set up api context in request -func WithAPIContext(req *http.Request, ctx *APIContext) *http.Request { - return req.WithContext(context.WithValue(req.Context(), apiContextKey, ctx)) -} - // GetAPIContext returns a context for API routes func GetAPIContext(req *http.Request) *APIContext { return req.Context().Value(apiContextKey).(*APIContext) @@ -195,21 +204,21 @@ func (ctx *APIContext) CheckForOTP() { } otpHeader := ctx.Req.Header.Get("X-Gitea-OTP") - twofa, err := auth.GetTwoFactorByUID(ctx.Context.Doer.ID) + twofa, err := auth.GetTwoFactorByUID(ctx.Doer.ID) if err != nil { if auth.IsErrTwoFactorNotEnrolled(err) { return // No 2FA enrollment for this user } - ctx.Context.Error(http.StatusInternalServerError) + ctx.Error(http.StatusInternalServerError, "GetTwoFactorByUID", err) return } ok, err := twofa.ValidateTOTP(otpHeader) if err != nil { - ctx.Context.Error(http.StatusInternalServerError) + ctx.Error(http.StatusInternalServerError, "ValidateTOTP", err) return } if !ok { - ctx.Context.Error(http.StatusUnauthorized) + ctx.Error(http.StatusUnauthorized, "", nil) return } } @@ -218,23 +227,17 @@ func (ctx *APIContext) CheckForOTP() { func APIContexter() func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - locale := middleware.Locale(w, req) - ctx := APIContext{ - Context: &Context{ - Resp: NewResponse(w), - Data: middleware.GetContextData(req.Context()), - Locale: locale, - Cache: cache.GetCache(), - Repo: &Repository{ - PullRequest: &PullRequest{}, - }, - Org: &Organization{}, - }, - Org: &APIOrganization{}, + base, baseCleanUp := NewBaseContext(w, req) + ctx := &APIContext{ + Base: base, + Cache: mc.GetCache(), + Repo: &Repository{PullRequest: &PullRequest{}}, + Org: &APIOrganization{}, } - defer ctx.Close() + defer baseCleanUp() - ctx.Req = WithAPIContext(WithContext(req, ctx.Context), &ctx) + ctx.Base.AppendContextValue(apiContextKey, ctx) + ctx.Base.AppendContextValueFunc(git.RepositoryContextKey, func() any { return ctx.Repo.GitRepo }) // If request sends files, parse them here otherwise the Query() can't be parsed and the CsrfToken will be invalid. if ctx.Req.Method == "POST" && strings.Contains(ctx.Req.Header.Get("Content-Type"), "multipart/form-data") { @@ -247,8 +250,6 @@ func APIContexter() func(http.Handler) http.Handler { httpcache.SetCacheControlInHeader(ctx.Resp.Header(), 0, "no-transform") ctx.Resp.Header().Set(`X-Frame-Options`, setting.CORSConfig.XFrameOptions) - ctx.Data["Context"] = &ctx - next.ServeHTTP(ctx.Resp, ctx.Req) }) } @@ -301,7 +302,7 @@ func ReferencesGitRepo(allowEmpty ...bool) func(ctx *APIContext) (cancel context return func() { // If it's been set to nil then assume someone else has closed it. if ctx.Repo.GitRepo != nil { - ctx.Repo.GitRepo.Close() + _ = ctx.Repo.GitRepo.Close() } } } @@ -337,7 +338,7 @@ func RepoRefForAPI(next http.Handler) http.Handler { } var err error - refName := getRefName(ctx.Context, RepoRefAny) + refName := getRefName(ctx.Base, ctx.Repo, RepoRefAny) if ctx.Repo.GitRepo.IsBranchExist(refName) { ctx.Repo.Commit, err = ctx.Repo.GitRepo.GetBranchCommit(refName) @@ -368,3 +369,53 @@ func RepoRefForAPI(next http.Handler) http.Handler { next.ServeHTTP(w, req) }) } + +// HasAPIError returns true if error occurs in form validation. +func (ctx *APIContext) HasAPIError() bool { + hasErr, ok := ctx.Data["HasError"] + if !ok { + return false + } + return hasErr.(bool) +} + +// GetErrMsg returns error message in form validation. +func (ctx *APIContext) GetErrMsg() string { + msg, _ := ctx.Data["ErrorMsg"].(string) + if msg == "" { + msg = "invalid form data" + } + return msg +} + +// NotFoundOrServerError use error check function to determine if the error +// is about not found. It responds with 404 status code for not found error, +// or error context description for logging purpose of 500 server error. +func (ctx *APIContext) NotFoundOrServerError(logMsg string, errCheck func(error) bool, logErr error) { + if errCheck(logErr) { + ctx.JSON(http.StatusNotFound, nil) + return + } + ctx.Error(http.StatusInternalServerError, "NotFoundOrServerError", logMsg) +} + +// IsUserSiteAdmin returns true if current user is a site admin +func (ctx *APIContext) IsUserSiteAdmin() bool { + return ctx.IsSigned && ctx.Doer.IsAdmin +} + +// IsUserRepoAdmin returns true if current user is admin in current repo +func (ctx *APIContext) IsUserRepoAdmin() bool { + return ctx.Repo.IsAdmin() +} + +// IsUserRepoWriter returns true if current user has write privilege in current repo +func (ctx *APIContext) IsUserRepoWriter(unitTypes []unit.Type) bool { + for _, unitType := range unitTypes { + if ctx.Repo.CanWrite(unitType) { + return true + } + } + + return false +} diff --git a/modules/context/base.go b/modules/context/base.go new file mode 100644 index 0000000000000..ac9b52d51cf34 --- /dev/null +++ b/modules/context/base.go @@ -0,0 +1,300 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package context + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "code.gitea.io/gitea/modules/httplib" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/translation" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web/middleware" + + "github.com/go-chi/chi/v5" +) + +type contextValuePair struct { + key any + valueFn func() any +} + +type Base struct { + originCtx context.Context + contextValues []contextValuePair + + Resp ResponseWriter + Req *http.Request + + // Data is prepared by ContextDataStore middleware, this field only refers to the pre-created/prepared ContextData. + // Although it's mainly used for MVC templates, sometimes it's also used to pass data between middlewares/handler + Data middleware.ContextData + + // Locale is mainly for Web context, although the API context also uses it in some cases: message response, form validation + Locale translation.Locale +} + +func (b *Base) Deadline() (deadline time.Time, ok bool) { + return b.originCtx.Deadline() +} + +func (b *Base) Done() <-chan struct{} { + return b.originCtx.Done() +} + +func (b *Base) Err() error { + return b.originCtx.Err() +} + +func (b *Base) Value(key any) any { + for _, pair := range b.contextValues { + if pair.key == key { + return pair.valueFn() + } + } + return b.originCtx.Value(key) +} + +func (b *Base) AppendContextValueFunc(key any, valueFn func() any) any { + b.contextValues = append(b.contextValues, contextValuePair{key, valueFn}) + return b +} + +func (b *Base) AppendContextValue(key, value any) any { + b.contextValues = append(b.contextValues, contextValuePair{key, func() any { return value }}) + return b +} + +func (b *Base) GetData() middleware.ContextData { + return b.Data +} + +// AppendAccessControlExposeHeaders append headers by name to "Access-Control-Expose-Headers" header +func (b *Base) AppendAccessControlExposeHeaders(names ...string) { + val := b.RespHeader().Get("Access-Control-Expose-Headers") + if len(val) != 0 { + b.RespHeader().Set("Access-Control-Expose-Headers", fmt.Sprintf("%s, %s", val, strings.Join(names, ", "))) + } else { + b.RespHeader().Set("Access-Control-Expose-Headers", strings.Join(names, ", ")) + } +} + +// SetTotalCountHeader set "X-Total-Count" header +func (b *Base) SetTotalCountHeader(total int64) { + b.RespHeader().Set("X-Total-Count", fmt.Sprint(total)) + b.AppendAccessControlExposeHeaders("X-Total-Count") +} + +// Written returns true if there are something sent to web browser +func (b *Base) Written() bool { + return b.Resp.Status() > 0 +} + +// Status writes status code +func (b *Base) Status(status int) { + b.Resp.WriteHeader(status) +} + +// Write writes data to web browser +func (b *Base) Write(bs []byte) (int, error) { + return b.Resp.Write(bs) +} + +// RespHeader returns the response header +func (b *Base) RespHeader() http.Header { + return b.Resp.Header() +} + +// Error returned an error to web browser +func (b *Base) Error(status int, contents ...string) { + v := http.StatusText(status) + if len(contents) > 0 { + v = contents[0] + } + http.Error(b.Resp, v, status) +} + +// JSON render content as JSON +func (b *Base) JSON(status int, content interface{}) { + b.Resp.Header().Set("Content-Type", "application/json;charset=utf-8") + b.Resp.WriteHeader(status) + if err := json.NewEncoder(b.Resp).Encode(content); err != nil { + log.Error("Render JSON failed: %v", err) + } +} + +// RemoteAddr returns the client machine ip address +func (b *Base) RemoteAddr() string { + return b.Req.RemoteAddr +} + +// Params returns the param on route +func (b *Base) Params(p string) string { + s, _ := url.PathUnescape(chi.URLParam(b.Req, strings.TrimPrefix(p, ":"))) + return s +} + +// ParamsInt64 returns the param on route as int64 +func (b *Base) ParamsInt64(p string) int64 { + v, _ := strconv.ParseInt(b.Params(p), 10, 64) + return v +} + +// SetParams set params into routes +func (b *Base) SetParams(k, v string) { + chiCtx := chi.RouteContext(b) + chiCtx.URLParams.Add(strings.TrimPrefix(k, ":"), url.PathEscape(v)) +} + +// FormString returns the first value matching the provided key in the form as a string +func (b *Base) FormString(key string) string { + return b.Req.FormValue(key) +} + +// FormStrings returns a string slice for the provided key from the form +func (b *Base) FormStrings(key string) []string { + if b.Req.Form == nil { + if err := b.Req.ParseMultipartForm(32 << 20); err != nil { + return nil + } + } + if v, ok := b.Req.Form[key]; ok { + return v + } + return nil +} + +// FormTrim returns the first value for the provided key in the form as a space trimmed string +func (b *Base) FormTrim(key string) string { + return strings.TrimSpace(b.Req.FormValue(key)) +} + +// FormInt returns the first value for the provided key in the form as an int +func (b *Base) FormInt(key string) int { + v, _ := strconv.Atoi(b.Req.FormValue(key)) + return v +} + +// FormInt64 returns the first value for the provided key in the form as an int64 +func (b *Base) FormInt64(key string) int64 { + v, _ := strconv.ParseInt(b.Req.FormValue(key), 10, 64) + return v +} + +// FormBool returns true if the value for the provided key in the form is "1", "true" or "on" +func (b *Base) FormBool(key string) bool { + s := b.Req.FormValue(key) + v, _ := strconv.ParseBool(s) + v = v || strings.EqualFold(s, "on") + return v +} + +// FormOptionalBool returns an OptionalBoolTrue or OptionalBoolFalse if the value +// for the provided key exists in the form else it returns OptionalBoolNone +func (b *Base) FormOptionalBool(key string) util.OptionalBool { + value := b.Req.FormValue(key) + if len(value) == 0 { + return util.OptionalBoolNone + } + s := b.Req.FormValue(key) + v, _ := strconv.ParseBool(s) + v = v || strings.EqualFold(s, "on") + return util.OptionalBoolOf(v) +} + +func (b *Base) SetFormString(key, value string) { + _ = b.Req.FormValue(key) // force parse form + b.Req.Form.Set(key, value) +} + +// PlainTextBytes renders bytes as plain text +func (b *Base) plainTextInternal(skip, status int, bs []byte) { + statusPrefix := status / 100 + if statusPrefix == 4 || statusPrefix == 5 { + log.Log(skip, log.TRACE, "plainTextInternal (status=%d): %s", status, string(bs)) + } + b.Resp.Header().Set("Content-Type", "text/plain;charset=utf-8") + b.Resp.Header().Set("X-Content-Type-Options", "nosniff") + b.Resp.WriteHeader(status) + if _, err := b.Resp.Write(bs); err != nil { + log.ErrorWithSkip(skip, "plainTextInternal (status=%d): write bytes failed: %v", status, err) + } +} + +// PlainTextBytes renders bytes as plain text +func (b *Base) PlainTextBytes(status int, bs []byte) { + b.plainTextInternal(2, status, bs) +} + +// PlainText renders content as plain text +func (b *Base) PlainText(status int, text string) { + b.plainTextInternal(2, status, []byte(text)) +} + +// Redirect redirects the request +func (b *Base) Redirect(location string, status ...int) { + code := http.StatusSeeOther + if len(status) == 1 { + code = status[0] + } + + if strings.Contains(location, "://") || strings.HasPrefix(location, "//") { + // Some browsers (Safari) have buggy behavior for Cookie + Cache + External Redirection, eg: /my-path => https://other/path + // 1. the first request to "/my-path" contains cookie + // 2. some time later, the request to "/my-path" doesn't contain cookie (caused by Prevent web tracking) + // 3. Gitea's Sessioner doesn't see the session cookie, so it generates a new session id, and returns it to browser + // 4. then the browser accepts the empty session, then the user is logged out + // So in this case, we should remove the session cookie from the response header + removeSessionCookieHeader(b.Resp) + } + http.Redirect(b.Resp, b.Req, location, code) +} + +type ServeHeaderOptions httplib.ServeHeaderOptions + +func (b *Base) SetServeHeaders(opt *ServeHeaderOptions) { + httplib.ServeSetHeaders(b.Resp, (*httplib.ServeHeaderOptions)(opt)) +} + +// ServeContent serves content to http request +func (b *Base) ServeContent(r io.ReadSeeker, opts *ServeHeaderOptions) { + httplib.ServeSetHeaders(b.Resp, (*httplib.ServeHeaderOptions)(opts)) + http.ServeContent(b.Resp, b.Req, opts.Filename, opts.LastModified, r) +} + +// Close frees all resources hold by Context +func (b *Base) cleanUp() { + if b.Req != nil && b.Req.MultipartForm != nil { + _ = b.Req.MultipartForm.RemoveAll() // remove the temp files buffered to tmp directory + } +} + +func (b *Base) Tr(msg string, args ...any) string { + return b.Locale.Tr(msg, args...) +} + +func (b *Base) TrN(cnt any, key1, keyN string, args ...any) string { + return b.Locale.TrN(cnt, key1, keyN, args...) +} + +func NewBaseContext(resp http.ResponseWriter, req *http.Request) (b *Base, closeFunc func()) { + b = &Base{ + originCtx: req.Context(), + Req: req, + Resp: WrapResponseWriter(resp), + Locale: middleware.Locale(resp, req), + Data: middleware.GetContextData(req.Context()), + } + b.AppendContextValue(translation.ContextKey, b.Locale) + b.Req = b.Req.WithContext(b) + return b, b.cleanUp +} diff --git a/modules/context/context.go b/modules/context/context.go index 9ba1985f36558..1e15081479ff8 100644 --- a/modules/context/context.go +++ b/modules/context/context.go @@ -5,7 +5,6 @@ package context import ( - "context" "html" "html/template" "io" @@ -36,38 +35,27 @@ type Render interface { // Context represents context of a request. type Context struct { - Resp ResponseWriter - Req *http.Request - Render Render + *Base - Data middleware.ContextData // data used by MVC templates - PageData map[string]any // data used by JavaScript modules in one page, it's `window.config.pageData` + Render Render + PageData map[string]any // data used by JavaScript modules in one page, it's `window.config.pageData` - Locale translation.Locale Cache cache.Cache Csrf CSRFProtector Flash *middleware.Flash Session session.Store - Link string // current request URL (without query string) - Doer *user_model.User + Link string // current request URL (without query string) + + Doer *user_model.User // current signed-in user IsSigned bool IsBasicAuth bool - ContextUser *user_model.User - Repo *Repository - Org *Organization - Package *Package -} + ContextUser *user_model.User // the user which is being visited, in most cases it differs from Doer -// Close frees all resources hold by Context -func (ctx *Context) Close() error { - var err error - if ctx.Req != nil && ctx.Req.MultipartForm != nil { - err = ctx.Req.MultipartForm.RemoveAll() // remove the temp files buffered to tmp directory - } - // TODO: close opened repo, and more - return err + Repo *Repository + Org *Organization + Package *Package } // TrHTMLEscapeArgs runs ".Locale.Tr()" but pre-escapes all arguments with html.EscapeString. @@ -80,55 +68,30 @@ func (ctx *Context) TrHTMLEscapeArgs(msg string, args ...string) string { return ctx.Locale.Tr(msg, trArgs...) } -func (ctx *Context) Tr(msg string, args ...any) string { - return ctx.Locale.Tr(msg, args...) -} - -func (ctx *Context) TrN(cnt any, key1, keyN string, args ...any) string { - return ctx.Locale.TrN(cnt, key1, keyN, args...) -} - -// Deadline is part of the interface for context.Context and we pass this to the request context -func (ctx *Context) Deadline() (deadline time.Time, ok bool) { - return ctx.Req.Context().Deadline() -} - -// Done is part of the interface for context.Context and we pass this to the request context -func (ctx *Context) Done() <-chan struct{} { - return ctx.Req.Context().Done() -} - -// Err is part of the interface for context.Context and we pass this to the request context -func (ctx *Context) Err() error { - return ctx.Req.Context().Err() -} - -// Value is part of the interface for context.Context and we pass this to the request context -func (ctx *Context) Value(key interface{}) interface{} { - if key == git.RepositoryContextKey && ctx.Repo != nil { - return ctx.Repo.GitRepo - } - if key == translation.ContextKey && ctx.Locale != nil { - return ctx.Locale - } - return ctx.Req.Context().Value(key) -} - type contextKeyType struct{} var contextKey interface{} = contextKeyType{} -// WithContext set up install context in request -func WithContext(req *http.Request, ctx *Context) *http.Request { - return req.WithContext(context.WithValue(req.Context(), contextKey, ctx)) +func GetContext(req *http.Request) *Context { + ctx, _ := req.Context().Value(contextKey).(*Context) + return ctx } -// GetContext retrieves install context from request -func GetContext(req *http.Request) *Context { - if ctx, ok := req.Context().Value(contextKey).(*Context); ok { - return ctx +// ValidateContext is a special context for form validation middleware. It may be different from other contexts. +type ValidateContext struct { + *Base +} + +// GetValidateContext gets a context for middleware form validation +func GetValidateContext(req *http.Request) (ctx *ValidateContext) { + if ctxAPI, ok := req.Context().Value(apiContextKey).(*APIContext); ok { + ctx = &ValidateContext{Base: ctxAPI.Base} + } else if ctxWeb, ok := req.Context().Value(contextKey).(*Context); ok { + ctx = &ValidateContext{Base: ctxWeb.Base} + } else { + panic("invalid context, expect either APIContext or Context") } - return nil + return ctx } // Contexter initializes a classic context for a request. @@ -150,20 +113,17 @@ func Contexter() func(next http.Handler) http.Handler { } return func(next http.Handler) http.Handler { return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { - ctx := Context{ - Resp: NewResponse(resp), + base, baseCleanUp := NewBaseContext(resp, req) + ctx := &Context{ + Base: base, Cache: mc.GetCache(), - Locale: middleware.Locale(resp, req), Link: setting.AppSubURL + strings.TrimSuffix(req.URL.EscapedPath(), "/"), Render: rnd, Session: session.GetSession(req), - Repo: &Repository{ - PullRequest: &PullRequest{}, - }, - Org: &Organization{}, - Data: middleware.GetContextData(req.Context()), + Repo: &Repository{PullRequest: &PullRequest{}}, + Org: &Organization{}, } - defer ctx.Close() + defer baseCleanUp() ctx.Data.MergeFrom(middleware.CommonTemplateContextData()) ctx.Data["Context"] = &ctx @@ -175,15 +135,17 @@ func Contexter() func(next http.Handler) http.Handler { ctx.PageData = map[string]any{} ctx.Data["PageData"] = ctx.PageData - ctx.Req = WithContext(req, &ctx) - ctx.Csrf = PrepareCSRFProtector(csrfOpts, &ctx) + ctx.Base.AppendContextValue(contextKey, ctx) + ctx.Base.AppendContextValueFunc(git.RepositoryContextKey, func() any { return ctx.Repo.GitRepo }) + + ctx.Csrf = PrepareCSRFProtector(csrfOpts, ctx) // Get the last flash message from cookie lastFlashCookie := middleware.GetSiteCookie(ctx.Req, CookieNameFlash) if vals, _ := url.ParseQuery(lastFlashCookie); len(vals) > 0 { // store last Flash message into the template data, to render it ctx.Data["Flash"] = &middleware.Flash{ - DataStore: &ctx, + DataStore: ctx, Values: vals, ErrorMsg: vals.Get("error"), SuccessMsg: vals.Get("success"), @@ -193,7 +155,7 @@ func Contexter() func(next http.Handler) http.Handler { } // prepare an empty Flash message for current request - ctx.Flash = &middleware.Flash{DataStore: &ctx, Values: url.Values{}} + ctx.Flash = &middleware.Flash{DataStore: ctx, Values: url.Values{}} ctx.Resp.Before(func(resp ResponseWriter) { if val := ctx.Flash.Encode(); val != "" { middleware.SetSiteCookie(ctx.Resp, CookieNameFlash, val, 0) @@ -235,3 +197,24 @@ func Contexter() func(next http.Handler) http.Handler { }) } } + +// HasError returns true if error occurs in form validation. +// Attention: this function changes ctx.Data and ctx.Flash +func (ctx *Context) HasError() bool { + hasErr, ok := ctx.Data["HasError"] + if !ok { + return false + } + ctx.Flash.ErrorMsg = ctx.GetErrMsg() + ctx.Data["Flash"] = ctx.Flash + return hasErr.(bool) +} + +// GetErrMsg returns error message in form validation. +func (ctx *Context) GetErrMsg() string { + msg, _ := ctx.Data["ErrorMsg"].(string) + if msg == "" { + msg = "invalid form data" + } + return msg +} diff --git a/modules/context/context_data.go b/modules/context/context_data.go deleted file mode 100644 index cdf4ff9afe3d9..0000000000000 --- a/modules/context/context_data.go +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright 2023 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package context - -import "code.gitea.io/gitea/modules/web/middleware" - -// GetData returns the data -func (ctx *Context) GetData() middleware.ContextData { - return ctx.Data -} - -// HasAPIError returns true if error occurs in form validation. -func (ctx *Context) HasAPIError() bool { - hasErr, ok := ctx.Data["HasError"] - if !ok { - return false - } - return hasErr.(bool) -} - -// GetErrMsg returns error message -func (ctx *Context) GetErrMsg() string { - return ctx.Data["ErrorMsg"].(string) -} - -// HasError returns true if error occurs in form validation. -// Attention: this function changes ctx.Data and ctx.Flash -func (ctx *Context) HasError() bool { - hasErr, ok := ctx.Data["HasError"] - if !ok { - return false - } - ctx.Flash.ErrorMsg = ctx.Data["ErrorMsg"].(string) - ctx.Data["Flash"] = ctx.Flash - return hasErr.(bool) -} - -// HasValue returns true if value of given name exists. -func (ctx *Context) HasValue(name string) bool { - _, ok := ctx.Data[name] - return ok -} diff --git a/modules/context/context_form.go b/modules/context/context_form.go deleted file mode 100644 index 5c02152582250..0000000000000 --- a/modules/context/context_form.go +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright 2021 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package context - -import ( - "strconv" - "strings" - - "code.gitea.io/gitea/modules/util" -) - -// FormString returns the first value matching the provided key in the form as a string -func (ctx *Context) FormString(key string) string { - return ctx.Req.FormValue(key) -} - -// FormStrings returns a string slice for the provided key from the form -func (ctx *Context) FormStrings(key string) []string { - if ctx.Req.Form == nil { - if err := ctx.Req.ParseMultipartForm(32 << 20); err != nil { - return nil - } - } - if v, ok := ctx.Req.Form[key]; ok { - return v - } - return nil -} - -// FormTrim returns the first value for the provided key in the form as a space trimmed string -func (ctx *Context) FormTrim(key string) string { - return strings.TrimSpace(ctx.Req.FormValue(key)) -} - -// FormInt returns the first value for the provided key in the form as an int -func (ctx *Context) FormInt(key string) int { - v, _ := strconv.Atoi(ctx.Req.FormValue(key)) - return v -} - -// FormInt64 returns the first value for the provided key in the form as an int64 -func (ctx *Context) FormInt64(key string) int64 { - v, _ := strconv.ParseInt(ctx.Req.FormValue(key), 10, 64) - return v -} - -// FormBool returns true if the value for the provided key in the form is "1", "true" or "on" -func (ctx *Context) FormBool(key string) bool { - s := ctx.Req.FormValue(key) - v, _ := strconv.ParseBool(s) - v = v || strings.EqualFold(s, "on") - return v -} - -// FormOptionalBool returns an OptionalBoolTrue or OptionalBoolFalse if the value -// for the provided key exists in the form else it returns OptionalBoolNone -func (ctx *Context) FormOptionalBool(key string) util.OptionalBool { - value := ctx.Req.FormValue(key) - if len(value) == 0 { - return util.OptionalBoolNone - } - s := ctx.Req.FormValue(key) - v, _ := strconv.ParseBool(s) - v = v || strings.EqualFold(s, "on") - return util.OptionalBoolOf(v) -} - -func (ctx *Context) SetFormString(key, value string) { - _ = ctx.Req.FormValue(key) // force parse form - ctx.Req.Form.Set(key, value) -} diff --git a/modules/context/context_request.go b/modules/context/context_request.go index 0b87552c085ba..984b9ac793e8d 100644 --- a/modules/context/context_request.go +++ b/modules/context/context_request.go @@ -6,36 +6,9 @@ package context import ( "io" "net/http" - "net/url" - "strconv" "strings" - - "github.com/go-chi/chi/v5" ) -// RemoteAddr returns the client machine ip address -func (ctx *Context) RemoteAddr() string { - return ctx.Req.RemoteAddr -} - -// Params returns the param on route -func (ctx *Context) Params(p string) string { - s, _ := url.PathUnescape(chi.URLParam(ctx.Req, strings.TrimPrefix(p, ":"))) - return s -} - -// ParamsInt64 returns the param on route as int64 -func (ctx *Context) ParamsInt64(p string) int64 { - v, _ := strconv.ParseInt(ctx.Params(p), 10, 64) - return v -} - -// SetParams set params into routes -func (ctx *Context) SetParams(k, v string) { - chiCtx := chi.RouteContext(ctx) - chiCtx.URLParams.Add(strings.TrimPrefix(k, ":"), url.PathEscape(v)) -} - // UploadStream returns the request body or the first form file // Only form files need to get closed. func (ctx *Context) UploadStream() (rd io.ReadCloser, needToClose bool, err error) { diff --git a/modules/context/context_response.go b/modules/context/context_response.go index 8adff96994bb9..aeeb51ba377f2 100644 --- a/modules/context/context_response.go +++ b/modules/context/context_response.go @@ -16,49 +16,17 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/web/middleware" ) -// SetTotalCountHeader set "X-Total-Count" header -func (ctx *Context) SetTotalCountHeader(total int64) { - ctx.RespHeader().Set("X-Total-Count", fmt.Sprint(total)) - ctx.AppendAccessControlExposeHeaders("X-Total-Count") -} - -// AppendAccessControlExposeHeaders append headers by name to "Access-Control-Expose-Headers" header -func (ctx *Context) AppendAccessControlExposeHeaders(names ...string) { - val := ctx.RespHeader().Get("Access-Control-Expose-Headers") - if len(val) != 0 { - ctx.RespHeader().Set("Access-Control-Expose-Headers", fmt.Sprintf("%s, %s", val, strings.Join(names, ", "))) - } else { - ctx.RespHeader().Set("Access-Control-Expose-Headers", strings.Join(names, ", ")) - } -} - -// Written returns true if there are something sent to web browser -func (ctx *Context) Written() bool { - return ctx.Resp.Status() > 0 -} - -// Status writes status code -func (ctx *Context) Status(status int) { - ctx.Resp.WriteHeader(status) -} - -// Write writes data to web browser -func (ctx *Context) Write(bs []byte) (int, error) { - return ctx.Resp.Write(bs) -} - // RedirectToUser redirect to a differently-named user -func RedirectToUser(ctx *Context, userName string, redirectUserID int64) { +func RedirectToUser(ctx *Base, userName string, redirectUserID int64) { user, err := user_model.GetUserByID(ctx, redirectUserID) if err != nil { - ctx.ServerError("GetUserByID", err) + ctx.Error(http.StatusInternalServerError, "unable to get user") return } @@ -211,69 +179,3 @@ func (ctx *Context) NotFoundOrServerError(logMsg string, errCheck func(error) bo } ctx.serverErrorInternal(logMsg, logErr) } - -// PlainTextBytes renders bytes as plain text -func (ctx *Context) plainTextInternal(skip, status int, bs []byte) { - statusPrefix := status / 100 - if statusPrefix == 4 || statusPrefix == 5 { - log.Log(skip, log.TRACE, "plainTextInternal (status=%d): %s", status, string(bs)) - } - ctx.Resp.Header().Set("Content-Type", "text/plain;charset=utf-8") - ctx.Resp.Header().Set("X-Content-Type-Options", "nosniff") - ctx.Resp.WriteHeader(status) - if _, err := ctx.Resp.Write(bs); err != nil { - log.ErrorWithSkip(skip, "plainTextInternal (status=%d): write bytes failed: %v", status, err) - } -} - -// PlainTextBytes renders bytes as plain text -func (ctx *Context) PlainTextBytes(status int, bs []byte) { - ctx.plainTextInternal(2, status, bs) -} - -// PlainText renders content as plain text -func (ctx *Context) PlainText(status int, text string) { - ctx.plainTextInternal(2, status, []byte(text)) -} - -// RespHeader returns the response header -func (ctx *Context) RespHeader() http.Header { - return ctx.Resp.Header() -} - -// Error returned an error to web browser -func (ctx *Context) Error(status int, contents ...string) { - v := http.StatusText(status) - if len(contents) > 0 { - v = contents[0] - } - http.Error(ctx.Resp, v, status) -} - -// JSON render content as JSON -func (ctx *Context) JSON(status int, content interface{}) { - ctx.Resp.Header().Set("Content-Type", "application/json;charset=utf-8") - ctx.Resp.WriteHeader(status) - if err := json.NewEncoder(ctx.Resp).Encode(content); err != nil { - ctx.ServerError("Render JSON failed", err) - } -} - -// Redirect redirects the request -func (ctx *Context) Redirect(location string, status ...int) { - code := http.StatusSeeOther - if len(status) == 1 { - code = status[0] - } - - if strings.Contains(location, "://") || strings.HasPrefix(location, "//") { - // Some browsers (Safari) have buggy behavior for Cookie + Cache + External Redirection, eg: /my-path => https://other/path - // 1. the first request to "/my-path" contains cookie - // 2. some time later, the request to "/my-path" doesn't contain cookie (caused by Prevent web tracking) - // 3. Gitea's Sessioner doesn't see the session cookie, so it generates a new session id, and returns it to browser - // 4. then the browser accepts the empty session, then the user is logged out - // So in this case, we should remove the session cookie from the response header - removeSessionCookieHeader(ctx.Resp) - } - http.Redirect(ctx.Resp, ctx.Req, location, code) -} diff --git a/modules/context/context_serve.go b/modules/context/context_serve.go deleted file mode 100644 index 5569efbc7ebe4..0000000000000 --- a/modules/context/context_serve.go +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright 2023 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package context - -import ( - "io" - "net/http" - - "code.gitea.io/gitea/modules/httplib" -) - -type ServeHeaderOptions httplib.ServeHeaderOptions - -func (ctx *Context) SetServeHeaders(opt *ServeHeaderOptions) { - httplib.ServeSetHeaders(ctx.Resp, (*httplib.ServeHeaderOptions)(opt)) -} - -// ServeContent serves content to http request -func (ctx *Context) ServeContent(r io.ReadSeeker, opts *ServeHeaderOptions) { - httplib.ServeSetHeaders(ctx.Resp, (*httplib.ServeHeaderOptions)(opts)) - http.ServeContent(ctx.Resp, ctx.Req, opts.Filename, opts.LastModified, r) -} diff --git a/modules/context/org.go b/modules/context/org.go index 39a3038f910cb..355ba0ebd01f5 100644 --- a/modules/context/org.go +++ b/modules/context/org.go @@ -47,7 +47,7 @@ func GetOrganizationByParams(ctx *Context) { if organization.IsErrOrgNotExist(err) { redirectUserID, err := user_model.LookupUserRedirect(orgName) if err == nil { - RedirectToUser(ctx, orgName, redirectUserID) + RedirectToUser(ctx.Base, orgName, redirectUserID) } else if user_model.IsErrUserRedirectNotExist(err) { ctx.NotFound("GetUserByName", err) } else { diff --git a/modules/context/package.go b/modules/context/package.go index fe5bdac19d67e..b1fd7088ddc40 100644 --- a/modules/context/package.go +++ b/modules/context/package.go @@ -4,7 +4,6 @@ package context import ( - gocontext "context" "fmt" "net/http" @@ -16,7 +15,6 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/templates" - "code.gitea.io/gitea/modules/web/middleware" ) // Package contains owner, access mode and optional the package descriptor @@ -26,10 +24,16 @@ type Package struct { Descriptor *packages_model.PackageDescriptor } +type packageAssignmentCtx struct { + *Base + Doer *user_model.User + ContextUser *user_model.User +} + // PackageAssignment returns a middleware to handle Context.Package assignment func PackageAssignment() func(ctx *Context) { return func(ctx *Context) { - packageAssignment(ctx, func(status int, title string, obj interface{}) { + errorFn := func(status int, title string, obj interface{}) { err, ok := obj.(error) if !ok { err = fmt.Errorf("%s", obj) @@ -39,68 +43,72 @@ func PackageAssignment() func(ctx *Context) { } else { ctx.ServerError(title, err) } - }) + } + paCtx := &packageAssignmentCtx{Base: ctx.Base, Doer: ctx.Doer, ContextUser: ctx.ContextUser} + ctx.Package = packageAssignment(paCtx, errorFn) } } // PackageAssignmentAPI returns a middleware to handle Context.Package assignment func PackageAssignmentAPI() func(ctx *APIContext) { return func(ctx *APIContext) { - packageAssignment(ctx.Context, ctx.Error) + paCtx := &packageAssignmentCtx{Base: ctx.Base, Doer: ctx.Doer, ContextUser: ctx.ContextUser} + ctx.Package = packageAssignment(paCtx, ctx.Error) } } -func packageAssignment(ctx *Context, errCb func(int, string, interface{})) { - ctx.Package = &Package{ +func packageAssignment(ctx *packageAssignmentCtx, errCb func(int, string, interface{})) *Package { + pkg := &Package{ Owner: ctx.ContextUser, } - var err error - ctx.Package.AccessMode, err = determineAccessMode(ctx) + pkg.AccessMode, err = determineAccessMode(ctx.Base, pkg, ctx.Doer) if err != nil { errCb(http.StatusInternalServerError, "determineAccessMode", err) - return + return pkg } packageType := ctx.Params("type") name := ctx.Params("name") version := ctx.Params("version") if packageType != "" && name != "" && version != "" { - pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.Type(packageType), name, version) + pv, err := packages_model.GetVersionByNameAndVersion(ctx, pkg.Owner.ID, packages_model.Type(packageType), name, version) if err != nil { if err == packages_model.ErrPackageNotExist { errCb(http.StatusNotFound, "GetVersionByNameAndVersion", err) } else { errCb(http.StatusInternalServerError, "GetVersionByNameAndVersion", err) } - return + return pkg } - ctx.Package.Descriptor, err = packages_model.GetPackageDescriptor(ctx, pv) + pkg.Descriptor, err = packages_model.GetPackageDescriptor(ctx, pv) if err != nil { errCb(http.StatusInternalServerError, "GetPackageDescriptor", err) - return + return pkg } } + + return pkg } -func determineAccessMode(ctx *Context) (perm.AccessMode, error) { - if setting.Service.RequireSignInView && ctx.Doer == nil { +func determineAccessMode(ctx *Base, pkg *Package, doer *user_model.User) (perm.AccessMode, error) { + if setting.Service.RequireSignInView && doer == nil { return perm.AccessModeNone, nil } - if ctx.Doer != nil && !ctx.Doer.IsGhost() && (!ctx.Doer.IsActive || ctx.Doer.ProhibitLogin) { + if doer != nil && !doer.IsGhost() && (!doer.IsActive || doer.ProhibitLogin) { return perm.AccessModeNone, nil } // TODO: ActionUser permission check accessMode := perm.AccessModeNone - if ctx.Package.Owner.IsOrganization() { - org := organization.OrgFromUser(ctx.Package.Owner) + if pkg.Owner.IsOrganization() { + org := organization.OrgFromUser(pkg.Owner) - if ctx.Doer != nil && !ctx.Doer.IsGhost() { + if doer != nil && !doer.IsGhost() { // 1. If user is logged in, check all team packages permissions - teams, err := organization.GetUserOrgTeams(ctx, org.ID, ctx.Doer.ID) + teams, err := organization.GetUserOrgTeams(ctx, org.ID, doer.ID) if err != nil { return accessMode, err } @@ -110,19 +118,19 @@ func determineAccessMode(ctx *Context) (perm.AccessMode, error) { accessMode = perm } } - } else if organization.HasOrgOrUserVisible(ctx, ctx.Package.Owner, ctx.Doer) { + } else if organization.HasOrgOrUserVisible(ctx, pkg.Owner, doer) { // 2. If user is non-login, check if org is visible to non-login user accessMode = perm.AccessModeRead } } else { - if ctx.Doer != nil && !ctx.Doer.IsGhost() { + if doer != nil && !doer.IsGhost() { // 1. Check if user is package owner - if ctx.Doer.ID == ctx.Package.Owner.ID { + if doer.ID == pkg.Owner.ID { accessMode = perm.AccessModeOwner - } else if ctx.Package.Owner.Visibility == structs.VisibleTypePublic || ctx.Package.Owner.Visibility == structs.VisibleTypeLimited { // 2. Check if package owner is public or limited + } else if pkg.Owner.Visibility == structs.VisibleTypePublic || pkg.Owner.Visibility == structs.VisibleTypeLimited { // 2. Check if package owner is public or limited accessMode = perm.AccessModeRead } - } else if ctx.Package.Owner.Visibility == structs.VisibleTypePublic { // 3. Check if package owner is public + } else if pkg.Owner.Visibility == structs.VisibleTypePublic { // 3. Check if package owner is public accessMode = perm.AccessModeRead } } @@ -131,19 +139,18 @@ func determineAccessMode(ctx *Context) (perm.AccessMode, error) { } // PackageContexter initializes a package context for a request. -func PackageContexter(ctx gocontext.Context) func(next http.Handler) http.Handler { - rnd := templates.HTMLRenderer() +func PackageContexter() func(next http.Handler) http.Handler { + renderer := templates.HTMLRenderer() return func(next http.Handler) http.Handler { return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { - ctx := Context{ - Resp: NewResponse(resp), - Data: middleware.GetContextData(req.Context()), - Render: rnd, + base, baseCleanUp := NewBaseContext(resp, req) + ctx := &Context{ + Base: base, + Render: renderer, // it is still needed when rendering 500 page in a package handler } - defer ctx.Close() - - ctx.Req = WithContext(req, &ctx) + defer baseCleanUp() + ctx.Base.AppendContextValue(contextKey, ctx) next.ServeHTTP(ctx.Resp, ctx.Req) }) } diff --git a/modules/context/private.go b/modules/context/private.go index f621dd68390fe..41ca8a4709cea 100644 --- a/modules/context/private.go +++ b/modules/context/private.go @@ -11,13 +11,14 @@ import ( "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/process" - "code.gitea.io/gitea/modules/web/middleware" ) // PrivateContext represents a context for private routes type PrivateContext struct { - *Context + *Base Override context.Context + + Repo *Repository } // Deadline is part of the interface for context.Context and we pass this to the request context @@ -25,7 +26,7 @@ func (ctx *PrivateContext) Deadline() (deadline time.Time, ok bool) { if ctx.Override != nil { return ctx.Override.Deadline() } - return ctx.Req.Context().Deadline() + return ctx.Base.Deadline() } // Done is part of the interface for context.Context and we pass this to the request context @@ -33,7 +34,7 @@ func (ctx *PrivateContext) Done() <-chan struct{} { if ctx.Override != nil { return ctx.Override.Done() } - return ctx.Req.Context().Done() + return ctx.Base.Done() } // Err is part of the interface for context.Context and we pass this to the request context @@ -41,16 +42,11 @@ func (ctx *PrivateContext) Err() error { if ctx.Override != nil { return ctx.Override.Err() } - return ctx.Req.Context().Err() + return ctx.Base.Err() } var privateContextKey interface{} = "default_private_context" -// WithPrivateContext set up private context in request -func WithPrivateContext(req *http.Request, ctx *PrivateContext) *http.Request { - return req.WithContext(context.WithValue(req.Context(), privateContextKey, ctx)) -} - // GetPrivateContext returns a context for Private routes func GetPrivateContext(req *http.Request) *PrivateContext { return req.Context().Value(privateContextKey).(*PrivateContext) @@ -60,16 +56,11 @@ func GetPrivateContext(req *http.Request) *PrivateContext { func PrivateContexter() func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - ctx := &PrivateContext{ - Context: &Context{ - Resp: NewResponse(w), - Data: middleware.GetContextData(req.Context()), - }, - } - defer ctx.Close() + base, baseCleanUp := NewBaseContext(w, req) + ctx := &PrivateContext{Base: base} + defer baseCleanUp() + ctx.Base.AppendContextValue(privateContextKey, ctx) - ctx.Req = WithPrivateContext(req, ctx) - ctx.Data["Context"] = ctx next.ServeHTTP(ctx.Resp, ctx.Req) }) } diff --git a/modules/context/repo.go b/modules/context/repo.go index 5e90e8aec0ec2..fd5f20857663e 100644 --- a/modules/context/repo.go +++ b/modules/context/repo.go @@ -331,13 +331,14 @@ func EarlyResponseForGoGetMeta(ctx *Context) { } // RedirectToRepo redirect to a differently-named repository -func RedirectToRepo(ctx *Context, redirectRepoID int64) { +func RedirectToRepo(ctx *Base, redirectRepoID int64) { ownerName := ctx.Params(":username") previousRepoName := ctx.Params(":reponame") repo, err := repo_model.GetRepositoryByID(ctx, redirectRepoID) if err != nil { - ctx.ServerError("GetRepositoryByID", err) + log.Error("GetRepositoryByID: %v", err) + ctx.Error(http.StatusInternalServerError, "GetRepositoryByID") return } @@ -456,7 +457,7 @@ func RepoAssignment(ctx *Context) (cancel context.CancelFunc) { } if redirectUserID, err := user_model.LookupUserRedirect(userName); err == nil { - RedirectToUser(ctx, userName, redirectUserID) + RedirectToUser(ctx.Base, userName, redirectUserID) } else if user_model.IsErrUserRedirectNotExist(err) { ctx.NotFound("GetUserByName", nil) } else { @@ -498,7 +499,7 @@ func RepoAssignment(ctx *Context) (cancel context.CancelFunc) { if repo_model.IsErrRepoNotExist(err) { redirectRepoID, err := repo_model.LookupRedirect(owner.ID, repoName) if err == nil { - RedirectToRepo(ctx, redirectRepoID) + RedirectToRepo(ctx.Base, redirectRepoID) } else if repo_model.IsErrRedirectNotExist(err) { if ctx.FormString("go-get") == "1" { EarlyResponseForGoGetMeta(ctx) @@ -781,46 +782,46 @@ func (rt RepoRefType) RefTypeIncludesTags() bool { return false } -func getRefNameFromPath(ctx *Context, path string, isExist func(string) bool) string { +func getRefNameFromPath(ctx *Base, repo *Repository, path string, isExist func(string) bool) string { refName := "" parts := strings.Split(path, "/") for i, part := range parts { refName = strings.TrimPrefix(refName+"/"+part, "/") if isExist(refName) { - ctx.Repo.TreePath = strings.Join(parts[i+1:], "/") + repo.TreePath = strings.Join(parts[i+1:], "/") return refName } } return "" } -func getRefName(ctx *Context, pathType RepoRefType) string { +func getRefName(ctx *Base, repo *Repository, pathType RepoRefType) string { path := ctx.Params("*") switch pathType { case RepoRefLegacy, RepoRefAny: - if refName := getRefName(ctx, RepoRefBranch); len(refName) > 0 { + if refName := getRefName(ctx, repo, RepoRefBranch); len(refName) > 0 { return refName } - if refName := getRefName(ctx, RepoRefTag); len(refName) > 0 { + if refName := getRefName(ctx, repo, RepoRefTag); len(refName) > 0 { return refName } // For legacy and API support only full commit sha parts := strings.Split(path, "/") if len(parts) > 0 && len(parts[0]) == git.SHAFullLength { - ctx.Repo.TreePath = strings.Join(parts[1:], "/") + repo.TreePath = strings.Join(parts[1:], "/") return parts[0] } - if refName := getRefName(ctx, RepoRefBlob); len(refName) > 0 { + if refName := getRefName(ctx, repo, RepoRefBlob); len(refName) > 0 { return refName } - ctx.Repo.TreePath = path - return ctx.Repo.Repository.DefaultBranch + repo.TreePath = path + return repo.Repository.DefaultBranch case RepoRefBranch: - ref := getRefNameFromPath(ctx, path, ctx.Repo.GitRepo.IsBranchExist) + ref := getRefNameFromPath(ctx, repo, path, repo.GitRepo.IsBranchExist) if len(ref) == 0 { // maybe it's a renamed branch - return getRefNameFromPath(ctx, path, func(s string) bool { - b, exist, err := git_model.FindRenamedBranch(ctx, ctx.Repo.Repository.ID, s) + return getRefNameFromPath(ctx, repo, path, func(s string) bool { + b, exist, err := git_model.FindRenamedBranch(ctx, repo.Repository.ID, s) if err != nil { log.Error("FindRenamedBranch", err) return false @@ -839,15 +840,15 @@ func getRefName(ctx *Context, pathType RepoRefType) string { return ref case RepoRefTag: - return getRefNameFromPath(ctx, path, ctx.Repo.GitRepo.IsTagExist) + return getRefNameFromPath(ctx, repo, path, repo.GitRepo.IsTagExist) case RepoRefCommit: parts := strings.Split(path, "/") if len(parts) > 0 && len(parts[0]) >= 7 && len(parts[0]) <= git.SHAFullLength { - ctx.Repo.TreePath = strings.Join(parts[1:], "/") + repo.TreePath = strings.Join(parts[1:], "/") return parts[0] } case RepoRefBlob: - _, err := ctx.Repo.GitRepo.GetBlob(path) + _, err := repo.GitRepo.GetBlob(path) if err != nil { return "" } @@ -922,7 +923,7 @@ func RepoRefByType(refType RepoRefType, ignoreNotExistErr ...bool) func(*Context } ctx.Repo.IsViewBranch = true } else { - refName = getRefName(ctx, refType) + refName = getRefName(ctx.Base, ctx.Repo, refType) ctx.Repo.RefName = refName isRenamedBranch, has := ctx.Data["IsRenamedBranch"].(bool) if isRenamedBranch && has { diff --git a/modules/context/response.go b/modules/context/response.go index 40eb5c0d35d4f..ca52ea137d28c 100644 --- a/modules/context/response.go +++ b/modules/context/response.go @@ -10,10 +10,9 @@ import ( // ResponseWriter represents a response writer for HTTP type ResponseWriter interface { http.ResponseWriter - Flush() + http.Flusher Status() int Before(func(ResponseWriter)) - Size() int } var _ ResponseWriter = &Response{} @@ -27,11 +26,6 @@ type Response struct { beforeExecuted bool } -// Size return written size -func (r *Response) Size() int { - return r.written -} - // Write writes bytes to HTTP endpoint func (r *Response) Write(bs []byte) (int, error) { if !r.beforeExecuted { @@ -65,7 +59,7 @@ func (r *Response) WriteHeader(statusCode int) { } } -// Flush flush cached data +// Flush flushes cached data func (r *Response) Flush() { if f, ok := r.ResponseWriter.(http.Flusher); ok { f.Flush() @@ -83,8 +77,7 @@ func (r *Response) Before(f func(ResponseWriter)) { r.befores = append(r.befores, f) } -// NewResponse creates a response -func NewResponse(resp http.ResponseWriter) *Response { +func WrapResponseWriter(resp http.ResponseWriter) *Response { if v, ok := resp.(*Response); ok { return v } diff --git a/modules/context/utils.go b/modules/context/utils.go index 1fa99953a23fc..c0f619aa237e7 100644 --- a/modules/context/utils.go +++ b/modules/context/utils.go @@ -10,7 +10,7 @@ import ( ) // GetQueryBeforeSince return parsed time (unix format) from URL query's before and since -func GetQueryBeforeSince(ctx *Context) (before, since int64, err error) { +func GetQueryBeforeSince(ctx *Base) (before, since int64, err error) { qCreatedBefore, err := prepareQueryArg(ctx, "before") if err != nil { return 0, 0, err @@ -48,7 +48,7 @@ func parseTime(value string) (int64, error) { } // prepareQueryArg unescape and trim a query arg -func prepareQueryArg(ctx *Context, name string) (value string, err error) { +func prepareQueryArg(ctx *Base, name string) (value string, err error) { value, err = url.PathUnescape(ctx.FormString(name)) value = strings.TrimSpace(value) return value, err diff --git a/modules/test/context_tests.go b/modules/test/context_tests.go index 5ba2126126147..349c7e3e8045f 100644 --- a/modules/test/context_tests.go +++ b/modules/test/context_tests.go @@ -4,7 +4,7 @@ package test import ( - scontext "context" + gocontext "context" "io" "net/http" "net/http/httptest" @@ -28,18 +28,33 @@ import ( // MockContext mock context for unit tests // TODO: move this function to other packages, because it depends on "models" package func MockContext(t *testing.T, path string) *context.Context { - resp := &mockResponseWriter{} - ctx := context.Context{ + resp := httptest.NewRecorder() + requestURL, err := url.Parse(path) + assert.NoError(t, err) + req := &http.Request{ + URL: requestURL, + Form: url.Values{}, + } + + base, baseCleanUp := context.NewBaseContext(resp, req) + base.Data = middleware.ContextData{} + base.Locale = &translation.MockLocale{} + ctx := &context.Context{ + Base: base, Render: &mockRender{}, - Data: make(middleware.ContextData), - Flash: &middleware.Flash{ - Values: make(url.Values), - }, - Resp: context.NewResponse(resp), - Locale: &translation.MockLocale{}, + Flash: &middleware.Flash{Values: url.Values{}}, } - defer ctx.Close() + _ = baseCleanUp // during test, it doesn't need to do clean up. TODO: this can be improved later + chiCtx := chi.NewRouteContext() + ctx.Base.AppendContextValue(chi.RouteCtxKey, chiCtx) + return ctx +} + +// MockAPIContext mock context for unit tests +// TODO: move this function to other packages, because it depends on "models" package +func MockAPIContext(t *testing.T, path string) *context.APIContext { + resp := httptest.NewRecorder() requestURL, err := url.Parse(path) assert.NoError(t, err) req := &http.Request{ @@ -47,41 +62,79 @@ func MockContext(t *testing.T, path string) *context.Context { Form: url.Values{}, } + base, baseCleanUp := context.NewBaseContext(resp, req) + base.Data = middleware.ContextData{} + base.Locale = &translation.MockLocale{} + ctx := &context.APIContext{Base: base} + _ = baseCleanUp // during test, it doesn't need to do clean up. TODO: this can be improved later + chiCtx := chi.NewRouteContext() - req = req.WithContext(scontext.WithValue(req.Context(), chi.RouteCtxKey, chiCtx)) - ctx.Req = context.WithContext(req, &ctx) - return &ctx + ctx.Base.AppendContextValue(chi.RouteCtxKey, chiCtx) + return ctx } // LoadRepo load a repo into a test context. -func LoadRepo(t *testing.T, ctx *context.Context, repoID int64) { - ctx.Repo = &context.Repository{} - ctx.Repo.Repository = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID}) +func LoadRepo(t *testing.T, ctx gocontext.Context, repoID int64) { + var doer *user_model.User + repo := &context.Repository{} + switch ctx := ctx.(type) { + case *context.Context: + ctx.Repo = repo + doer = ctx.Doer + case *context.APIContext: + ctx.Repo = repo + doer = ctx.Doer + default: + assert.Fail(t, "context is not *context.Context or *context.APIContext") + return + } + + repo.Repository = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID}) var err error - ctx.Repo.Owner, err = user_model.GetUserByID(ctx, ctx.Repo.Repository.OwnerID) + repo.Owner, err = user_model.GetUserByID(ctx, repo.Repository.OwnerID) assert.NoError(t, err) - ctx.Repo.RepoLink = ctx.Repo.Repository.Link() - ctx.Repo.Permission, err = access_model.GetUserRepoPermission(ctx, ctx.Repo.Repository, ctx.Doer) + repo.RepoLink = repo.Repository.Link() + repo.Permission, err = access_model.GetUserRepoPermission(ctx, repo.Repository, doer) assert.NoError(t, err) } // LoadRepoCommit loads a repo's commit into a test context. -func LoadRepoCommit(t *testing.T, ctx *context.Context) { - gitRepo, err := git.OpenRepository(ctx, ctx.Repo.Repository.RepoPath()) +func LoadRepoCommit(t *testing.T, ctx gocontext.Context) { + var repo *context.Repository + switch ctx := ctx.(type) { + case *context.Context: + repo = ctx.Repo + case *context.APIContext: + repo = ctx.Repo + default: + assert.Fail(t, "context is not *context.Context or *context.APIContext") + return + } + + gitRepo, err := git.OpenRepository(ctx, repo.Repository.RepoPath()) assert.NoError(t, err) defer gitRepo.Close() branch, err := gitRepo.GetHEADBranch() assert.NoError(t, err) assert.NotNil(t, branch) if branch != nil { - ctx.Repo.Commit, err = gitRepo.GetBranchCommit(branch.Name) + repo.Commit, err = gitRepo.GetBranchCommit(branch.Name) assert.NoError(t, err) } } // LoadUser load a user into a test context. -func LoadUser(t *testing.T, ctx *context.Context, userID int64) { - ctx.Doer = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userID}) +func LoadUser(t *testing.T, ctx gocontext.Context, userID int64) { + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userID}) + switch ctx := ctx.(type) { + case *context.Context: + ctx.Doer = doer + case *context.APIContext: + ctx.Doer = doer + default: + assert.Fail(t, "context is not *context.Context or *context.APIContext") + return + } } // LoadGitRepo load a git repo into a test context. Requires that ctx.Repo has @@ -93,32 +146,6 @@ func LoadGitRepo(t *testing.T, ctx *context.Context) { assert.NoError(t, err) } -type mockResponseWriter struct { - httptest.ResponseRecorder - size int -} - -func (rw *mockResponseWriter) Write(b []byte) (int, error) { - rw.size += len(b) - return rw.ResponseRecorder.Write(b) -} - -func (rw *mockResponseWriter) Status() int { - return rw.ResponseRecorder.Code -} - -func (rw *mockResponseWriter) Written() bool { - return rw.ResponseRecorder.Code > 0 -} - -func (rw *mockResponseWriter) Size() int { - return rw.size -} - -func (rw *mockResponseWriter) Push(target string, opts *http.PushOptions) error { - return nil -} - type mockRender struct{} func (tr *mockRender) TemplateLookup(tmpl string) (templates.TemplateExecutor, error) { diff --git a/modules/translation/translation.go b/modules/translation/translation.go index 49dfa84d1b0ef..dba4de6607da0 100644 --- a/modules/translation/translation.go +++ b/modules/translation/translation.go @@ -38,10 +38,12 @@ type LangType struct { } var ( - lock *sync.RWMutex + lock *sync.RWMutex + + allLangs []*LangType + allLangMap map[string]*LangType + matcher language.Matcher - allLangs []*LangType - allLangMap map[string]*LangType supportedTags []language.Tag ) @@ -251,3 +253,9 @@ func (l *locale) PrettyNumber(v any) string { } return l.msgPrinter.Sprintf("%v", number.Decimal(v)) } + +func init() { + // prepare a default matcher, especially for tests + supportedTags = []language.Tag{language.English} + matcher = language.NewMatcher(supportedTags) +} diff --git a/modules/web/handler.go b/modules/web/handler.go index bfb83820c8854..5013bac93f64d 100644 --- a/modules/web/handler.go +++ b/modules/web/handler.go @@ -10,6 +10,7 @@ import ( "reflect" "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/web/routing" ) @@ -25,6 +26,10 @@ var argTypeProvider = map[reflect.Type]func(req *http.Request) ResponseStatusPro reflect.TypeOf(&context.PrivateContext{}): func(req *http.Request) ResponseStatusProvider { return context.GetPrivateContext(req) }, } +func RegisterHandleTypeProvider[T any](fn func(req *http.Request) ResponseStatusProvider) { + argTypeProvider[reflect.TypeOf((*T)(nil)).Elem()] = fn +} + // responseWriter is a wrapper of http.ResponseWriter, to check whether the response has been written type responseWriter struct { respWriter http.ResponseWriter @@ -78,7 +83,13 @@ func preCheckHandler(fn reflect.Value, argsIn []reflect.Value) { } } -func prepareHandleArgsIn(resp http.ResponseWriter, req *http.Request, fn reflect.Value) []reflect.Value { +func prepareHandleArgsIn(resp http.ResponseWriter, req *http.Request, fn reflect.Value, fnInfo *routing.FuncInfo) []reflect.Value { + defer func() { + if err := recover(); err != nil { + log.Error("unable to prepare handler arguments for %s: %v", fnInfo.String(), err) + panic(err) + } + }() isPreCheck := req == nil argsIn := make([]reflect.Value, fn.Type().NumIn()) @@ -155,7 +166,7 @@ func toHandlerProvider(handler any) func(next http.Handler) http.Handler { } // prepare the arguments for the handler and do pre-check - argsIn := prepareHandleArgsIn(resp, req, fn) + argsIn := prepareHandleArgsIn(resp, req, fn, funcInfo) if req == nil { preCheckHandler(fn, argsIn) return // it's doing pre-check, just return diff --git a/routers/api/actions/artifacts.go b/routers/api/actions/artifacts.go index 61d432c8621e0..4b10cd7ad1108 100644 --- a/routers/api/actions/artifacts.go +++ b/routers/api/actions/artifacts.go @@ -3,7 +3,7 @@ package actions -// Github Actions Artifacts API Simple Description +// GitHub Actions Artifacts API Simple Description // // 1. Upload artifact // 1.1. Post upload url @@ -63,7 +63,6 @@ package actions import ( "compress/gzip" - gocontext "context" "crypto/md5" "encoding/base64" "errors" @@ -92,9 +91,25 @@ const ( const artifactRouteBase = "/_apis/pipelines/workflows/{run_id}/artifacts" -func ArtifactsRoutes(goctx gocontext.Context, prefix string) *web.Route { +type artifactContextKeyType struct{} + +var artifactContextKey = artifactContextKeyType{} + +type ArtifactContext struct { + *context.Base + + ActionTask *actions.ActionTask +} + +func init() { + web.RegisterHandleTypeProvider[*ArtifactContext](func(req *http.Request) web.ResponseStatusProvider { + return req.Context().Value(artifactContextKey).(*ArtifactContext) + }) +} + +func ArtifactsRoutes(prefix string) *web.Route { m := web.NewRoute() - m.Use(withContexter(goctx)) + m.Use(ArtifactContexter()) r := artifactRoutes{ prefix: prefix, @@ -115,15 +130,14 @@ func ArtifactsRoutes(goctx gocontext.Context, prefix string) *web.Route { return m } -// withContexter initializes a package context for a request. -func withContexter(goctx gocontext.Context) func(next http.Handler) http.Handler { +func ArtifactContexter() func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { - ctx := context.Context{ - Resp: context.NewResponse(resp), - Data: map[string]interface{}{}, - } - defer ctx.Close() + base, baseCleanUp := context.NewBaseContext(resp, req) + defer baseCleanUp() + + ctx := &ArtifactContext{Base: base} + ctx.AppendContextValue(artifactContextKey, ctx) // action task call server api with Bearer ACTIONS_RUNTIME_TOKEN // we should verify the ACTIONS_RUNTIME_TOKEN @@ -132,6 +146,7 @@ func withContexter(goctx gocontext.Context) func(next http.Handler) http.Handler ctx.Error(http.StatusUnauthorized, "Bad authorization header") return } + authToken := strings.TrimPrefix(authHeader, "Bearer ") task, err := actions.GetRunningTaskByToken(req.Context(), authToken) if err != nil { @@ -139,16 +154,14 @@ func withContexter(goctx gocontext.Context) func(next http.Handler) http.Handler ctx.Error(http.StatusInternalServerError, "Error runner api getting task") return } - ctx.Data["task"] = task - if err := task.LoadJob(goctx); err != nil { + if err := task.LoadJob(req.Context()); err != nil { log.Error("Error runner api getting job: %v", err) ctx.Error(http.StatusInternalServerError, "Error runner api getting job") return } - ctx.Req = context.WithContext(req, &ctx) - + ctx.ActionTask = task next.ServeHTTP(ctx.Resp, ctx.Req) }) } @@ -175,13 +188,8 @@ type getUploadArtifactResponse struct { FileContainerResourceURL string `json:"fileContainerResourceUrl"` } -func (ar artifactRoutes) validateRunID(ctx *context.Context) (*actions.ActionTask, int64, bool) { - task, ok := ctx.Data["task"].(*actions.ActionTask) - if !ok { - log.Error("Error getting task in context") - ctx.Error(http.StatusInternalServerError, "Error getting task in context") - return nil, 0, false - } +func (ar artifactRoutes) validateRunID(ctx *ArtifactContext) (*actions.ActionTask, int64, bool) { + task := ctx.ActionTask runID := ctx.ParamsInt64("run_id") if task.Job.RunID != runID { log.Error("Error runID not match") @@ -192,7 +200,7 @@ func (ar artifactRoutes) validateRunID(ctx *context.Context) (*actions.ActionTas } // getUploadArtifactURL generates a URL for uploading an artifact -func (ar artifactRoutes) getUploadArtifactURL(ctx *context.Context) { +func (ar artifactRoutes) getUploadArtifactURL(ctx *ArtifactContext) { task, runID, ok := ar.validateRunID(ctx) if !ok { return @@ -220,7 +228,7 @@ func (ar artifactRoutes) getUploadArtifactURL(ctx *context.Context) { // getUploadFileSize returns the size of the file to be uploaded. // The raw size is the size of the file as reported by the header X-TFS-FileLength. -func (ar artifactRoutes) getUploadFileSize(ctx *context.Context) (int64, int64, error) { +func (ar artifactRoutes) getUploadFileSize(ctx *ArtifactContext) (int64, int64, error) { contentLength := ctx.Req.ContentLength xTfsLength, _ := strconv.ParseInt(ctx.Req.Header.Get(artifactXTfsFileLengthHeader), 10, 64) if xTfsLength > 0 { @@ -229,7 +237,7 @@ func (ar artifactRoutes) getUploadFileSize(ctx *context.Context) (int64, int64, return contentLength, contentLength, nil } -func (ar artifactRoutes) saveUploadChunk(ctx *context.Context, +func (ar artifactRoutes) saveUploadChunk(ctx *ArtifactContext, artifact *actions.ActionArtifact, contentSize, runID int64, ) (int64, error) { @@ -273,7 +281,7 @@ func (ar artifactRoutes) saveUploadChunk(ctx *context.Context, // The rules are from https://github.com/actions/toolkit/blob/main/packages/artifact/src/internal/path-and-artifact-name-validation.ts#L32 var invalidArtifactNameChars = strings.Join([]string{"\\", "/", "\"", ":", "<", ">", "|", "*", "?", "\r", "\n"}, "") -func (ar artifactRoutes) uploadArtifact(ctx *context.Context) { +func (ar artifactRoutes) uploadArtifact(ctx *ArtifactContext) { _, runID, ok := ar.validateRunID(ctx) if !ok { return @@ -341,7 +349,7 @@ func (ar artifactRoutes) uploadArtifact(ctx *context.Context) { // comfirmUploadArtifact comfirm upload artifact. // if all chunks are uploaded, merge them to one file. -func (ar artifactRoutes) comfirmUploadArtifact(ctx *context.Context) { +func (ar artifactRoutes) comfirmUploadArtifact(ctx *ArtifactContext) { _, runID, ok := ar.validateRunID(ctx) if !ok { return @@ -364,7 +372,7 @@ type chunkItem struct { Path string } -func (ar artifactRoutes) mergeArtifactChunks(ctx *context.Context, runID int64) error { +func (ar artifactRoutes) mergeArtifactChunks(ctx *ArtifactContext, runID int64) error { storageDir := fmt.Sprintf("tmp%d", runID) var chunks []*chunkItem if err := ar.fs.IterateObjects(storageDir, func(path string, obj storage.Object) error { @@ -415,14 +423,20 @@ func (ar artifactRoutes) mergeArtifactChunks(ctx *context.Context, runID int64) // use multiReader readers := make([]io.Reader, 0, len(allChunks)) - readerClosers := make([]io.Closer, 0, len(allChunks)) + closeReaders := func() { + for _, r := range readers { + _ = r.(io.Closer).Close() // it guarantees to be io.Closer by the following loop's Open function + } + readers = nil + } + defer closeReaders() + for _, c := range allChunks { - reader, err := ar.fs.Open(c.Path) - if err != nil { + var readCloser io.ReadCloser + if readCloser, err = ar.fs.Open(c.Path); err != nil { return fmt.Errorf("open chunk error: %v, %s", err, c.Path) } - readers = append(readers, reader) - readerClosers = append(readerClosers, reader) + readers = append(readers, readCloser) } mergedReader := io.MultiReader(readers...) @@ -445,11 +459,6 @@ func (ar artifactRoutes) mergeArtifactChunks(ctx *context.Context, runID int64) return fmt.Errorf("merged file size is not equal to chunk length") } - // close readers - for _, r := range readerClosers { - r.Close() - } - // save storage path to artifact log.Debug("[artifact] merge chunks to artifact: %d, %s", artifact.ID, storagePath) artifact.StoragePath = storagePath @@ -458,6 +467,8 @@ func (ar artifactRoutes) mergeArtifactChunks(ctx *context.Context, runID int64) return fmt.Errorf("update artifact error: %v", err) } + closeReaders() // close before delete + // drop chunks for _, c := range cs { if err := ar.fs.Delete(c.Path); err != nil { @@ -479,21 +490,21 @@ type ( } ) -func (ar artifactRoutes) listArtifacts(ctx *context.Context) { +func (ar artifactRoutes) listArtifacts(ctx *ArtifactContext) { _, runID, ok := ar.validateRunID(ctx) if !ok { return } - artficats, err := actions.ListArtifactsByRunID(ctx, runID) + artifacts, err := actions.ListArtifactsByRunID(ctx, runID) if err != nil { log.Error("Error getting artifacts: %v", err) ctx.Error(http.StatusInternalServerError, err.Error()) return } - artficatsData := make([]listArtifactsResponseItem, 0, len(artficats)) - for _, a := range artficats { + artficatsData := make([]listArtifactsResponseItem, 0, len(artifacts)) + for _, a := range artifacts { artficatsData = append(artficatsData, listArtifactsResponseItem{ Name: a.ArtifactName, FileContainerResourceURL: ar.buildArtifactURL(runID, a.ID, "path"), @@ -517,7 +528,7 @@ type ( } ) -func (ar artifactRoutes) getDownloadArtifactURL(ctx *context.Context) { +func (ar artifactRoutes) getDownloadArtifactURL(ctx *ArtifactContext) { _, runID, ok := ar.validateRunID(ctx) if !ok { return @@ -546,7 +557,7 @@ func (ar artifactRoutes) getDownloadArtifactURL(ctx *context.Context) { ctx.JSON(http.StatusOK, respData) } -func (ar artifactRoutes) downloadArtifact(ctx *context.Context) { +func (ar artifactRoutes) downloadArtifact(ctx *ArtifactContext) { _, runID, ok := ar.validateRunID(ctx) if !ok { return diff --git a/routers/api/packages/api.go b/routers/api/packages/api.go index aaceb8a92b30e..e715997e82951 100644 --- a/routers/api/packages/api.go +++ b/routers/api/packages/api.go @@ -98,7 +98,7 @@ func verifyAuth(r *web.Route, authMethods []auth.Method) { func CommonRoutes(ctx gocontext.Context) *web.Route { r := web.NewRoute() - r.Use(context.PackageContexter(ctx)) + r.Use(context.PackageContexter()) verifyAuth(r, []auth.Method{ &auth.OAuth2{}, @@ -574,7 +574,7 @@ func CommonRoutes(ctx gocontext.Context) *web.Route { func ContainerRoutes(ctx gocontext.Context) *web.Route { r := web.NewRoute() - r.Use(context.PackageContexter(ctx)) + r.Use(context.PackageContexter()) verifyAuth(r, []auth.Method{ &auth.Basic{}, diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index a67a5420ac66e..f1e1cf946a01b 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -149,7 +149,7 @@ func repoAssignment() func(ctx *context.APIContext) { if err != nil { if user_model.IsErrUserNotExist(err) { if redirectUserID, err := user_model.LookupUserRedirect(userName); err == nil { - context.RedirectToUser(ctx.Context, userName, redirectUserID) + context.RedirectToUser(ctx.Base, userName, redirectUserID) } else if user_model.IsErrUserRedirectNotExist(err) { ctx.NotFound("GetUserByName", err) } else { @@ -170,7 +170,7 @@ func repoAssignment() func(ctx *context.APIContext) { if repo_model.IsErrRepoNotExist(err) { redirectRepoID, err := repo_model.LookupRedirect(owner.ID, repoName) if err == nil { - context.RedirectToRepo(ctx.Context, redirectRepoID) + context.RedirectToRepo(ctx.Base, redirectRepoID) } else if repo_model.IsErrRedirectNotExist(err) { ctx.NotFound() } else { @@ -274,7 +274,7 @@ func reqToken(requiredScope auth_model.AccessTokenScope) func(ctx *context.APICo ctx.Error(http.StatusForbidden, "reqToken", "token does not have required scope: "+requiredScope) return } - if ctx.Context.IsBasicAuth { + if ctx.IsBasicAuth { ctx.CheckForOTP() return } @@ -295,7 +295,7 @@ func reqExploreSignIn() func(ctx *context.APIContext) { func reqBasicAuth() func(ctx *context.APIContext) { return func(ctx *context.APIContext) { - if !ctx.Context.IsBasicAuth { + if !ctx.IsBasicAuth { ctx.Error(http.StatusUnauthorized, "reqBasicAuth", "auth required") return } @@ -375,7 +375,7 @@ func reqAnyRepoReader() func(ctx *context.APIContext) { // reqOrgOwnership user should be an organization owner, or a site admin func reqOrgOwnership() func(ctx *context.APIContext) { return func(ctx *context.APIContext) { - if ctx.Context.IsUserSiteAdmin() { + if ctx.IsUserSiteAdmin() { return } @@ -407,7 +407,7 @@ func reqOrgOwnership() func(ctx *context.APIContext) { // reqTeamMembership user should be an team member, or a site admin func reqTeamMembership() func(ctx *context.APIContext) { return func(ctx *context.APIContext) { - if ctx.Context.IsUserSiteAdmin() { + if ctx.IsUserSiteAdmin() { return } if ctx.Org.Team == nil { @@ -444,7 +444,7 @@ func reqTeamMembership() func(ctx *context.APIContext) { // reqOrgMembership user should be an organization member, or a site admin func reqOrgMembership() func(ctx *context.APIContext) { return func(ctx *context.APIContext) { - if ctx.Context.IsUserSiteAdmin() { + if ctx.IsUserSiteAdmin() { return } @@ -512,7 +512,7 @@ func orgAssignment(args ...bool) func(ctx *context.APIContext) { if organization.IsErrOrgNotExist(err) { redirectUserID, err := user_model.LookupUserRedirect(ctx.Params(":org")) if err == nil { - context.RedirectToUser(ctx.Context, ctx.Params(":org"), redirectUserID) + context.RedirectToUser(ctx.Base, ctx.Params(":org"), redirectUserID) } else if user_model.IsErrUserRedirectNotExist(err) { ctx.NotFound("GetOrgByName", err) } else { diff --git a/routers/api/v1/misc/markup.go b/routers/api/v1/misc/markup.go index 93d5754444440..7b24b353b63d0 100644 --- a/routers/api/v1/misc/markup.go +++ b/routers/api/v1/misc/markup.go @@ -41,7 +41,7 @@ func Markup(ctx *context.APIContext) { return } - common.RenderMarkup(ctx.Context, form.Mode, form.Text, form.Context, form.FilePath, form.Wiki) + common.RenderMarkup(ctx.Base, ctx.Repo, form.Mode, form.Text, form.Context, form.FilePath, form.Wiki) } // Markdown render markdown document to HTML @@ -76,7 +76,7 @@ func Markdown(ctx *context.APIContext) { mode = form.Mode } - common.RenderMarkup(ctx.Context, mode, form.Text, form.Context, "", form.Wiki) + common.RenderMarkup(ctx.Base, ctx.Repo, mode, form.Text, form.Context, "", form.Wiki) } // MarkdownRaw render raw markdown HTML diff --git a/routers/api/v1/misc/markup_test.go b/routers/api/v1/misc/markup_test.go index 68776613b272a..fdf540fd65b76 100644 --- a/routers/api/v1/misc/markup_test.go +++ b/routers/api/v1/misc/markup_test.go @@ -16,7 +16,6 @@ import ( "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" - "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web/middleware" @@ -30,26 +29,16 @@ const ( AppSubURL = AppURL + Repo + "/" ) -func createContext(req *http.Request) (*context.Context, *httptest.ResponseRecorder) { - rnd := templates.HTMLRenderer() +func createAPIContext(req *http.Request) (*context.APIContext, *httptest.ResponseRecorder) { resp := httptest.NewRecorder() - c := &context.Context{ - Req: req, - Resp: context.NewResponse(resp), - Render: rnd, - Data: make(middleware.ContextData), - } - defer c.Close() + base, baseCleanUp := context.NewBaseContext(resp, req) + base.Data = middleware.ContextData{} + c := &context.APIContext{Base: base} + _ = baseCleanUp // during test, it doesn't need to do clean up. TODO: this can be improved later return c, resp } -func wrap(ctx *context.Context) *context.APIContext { - return &context.APIContext{ - Context: ctx, - } -} - func testRenderMarkup(t *testing.T, mode, filePath, text, responseBody string, responseCode int) { setting.AppURL = AppURL @@ -65,8 +54,7 @@ func testRenderMarkup(t *testing.T, mode, filePath, text, responseBody string, r Method: "POST", URL: requrl, } - m, resp := createContext(req) - ctx := wrap(m) + ctx, resp := createAPIContext(req) options.Text = text web.SetForm(ctx, &options) @@ -90,8 +78,7 @@ func testRenderMarkdown(t *testing.T, mode, text, responseBody string, responseC Method: "POST", URL: requrl, } - m, resp := createContext(req) - ctx := wrap(m) + ctx, resp := createAPIContext(req) options.Text = text web.SetForm(ctx, &options) @@ -211,8 +198,7 @@ func TestAPI_RenderSimple(t *testing.T) { Method: "POST", URL: requrl, } - m, resp := createContext(req) - ctx := wrap(m) + ctx, resp := createAPIContext(req) for i := 0; i < len(simpleCases); i += 2 { options.Text = simpleCases[i] @@ -231,8 +217,7 @@ func TestAPI_RenderRaw(t *testing.T) { Method: "POST", URL: requrl, } - m, resp := createContext(req) - ctx := wrap(m) + ctx, resp := createAPIContext(req) for i := 0; i < len(simpleCases); i += 2 { ctx.Req.Body = io.NopCloser(strings.NewReader(simpleCases[i])) diff --git a/routers/api/v1/notify/notifications.go b/routers/api/v1/notify/notifications.go index 3b6a9bfdc297f..b22ea8a771579 100644 --- a/routers/api/v1/notify/notifications.go +++ b/routers/api/v1/notify/notifications.go @@ -25,7 +25,7 @@ func NewAvailable(ctx *context.APIContext) { } func getFindNotificationOptions(ctx *context.APIContext) *activities_model.FindNotificationOptions { - before, since, err := context.GetQueryBeforeSince(ctx.Context) + before, since, err := context.GetQueryBeforeSince(ctx.Base) if err != nil { ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err) return nil diff --git a/routers/api/v1/repo/file.go b/routers/api/v1/repo/file.go index eb63dda590247..786407827c9d8 100644 --- a/routers/api/v1/repo/file.go +++ b/routers/api/v1/repo/file.go @@ -80,7 +80,7 @@ func GetRawFile(ctx *context.APIContext) { ctx.RespHeader().Set(giteaObjectTypeHeader, string(files_service.GetObjectTypeFromTreeEntry(entry))) - if err := common.ServeBlob(ctx.Context, blob, lastModified); err != nil { + if err := common.ServeBlob(ctx.Base, ctx.Repo.TreePath, blob, lastModified); err != nil { ctx.Error(http.StatusInternalServerError, "ServeBlob", err) } } @@ -137,7 +137,7 @@ func GetRawFileOrLFS(ctx *context.APIContext) { } // OK not cached - serve! - if err := common.ServeBlob(ctx.Context, blob, lastModified); err != nil { + if err := common.ServeBlob(ctx.Base, ctx.Repo.TreePath, blob, lastModified); err != nil { ctx.ServerError("ServeBlob", err) } return @@ -159,7 +159,7 @@ func GetRawFileOrLFS(ctx *context.APIContext) { } if err := dataRc.Close(); err != nil { - log.Error("Error whilst closing blob %s reader in %-v. Error: %v", blob.ID, ctx.Context.Repo.Repository, err) + log.Error("Error whilst closing blob %s reader in %-v. Error: %v", blob.ID, ctx.Repo.Repository, err) } // Check if the blob represents a pointer @@ -173,7 +173,7 @@ func GetRawFileOrLFS(ctx *context.APIContext) { } // OK not cached - serve! - common.ServeContentByReader(ctx.Context, ctx.Repo.TreePath, blob.Size(), bytes.NewReader(buf)) + common.ServeContentByReader(ctx.Base, ctx.Repo.TreePath, blob.Size(), bytes.NewReader(buf)) return } @@ -187,7 +187,7 @@ func GetRawFileOrLFS(ctx *context.APIContext) { return } - common.ServeContentByReader(ctx.Context, ctx.Repo.TreePath, blob.Size(), bytes.NewReader(buf)) + common.ServeContentByReader(ctx.Base, ctx.Repo.TreePath, blob.Size(), bytes.NewReader(buf)) return } else if err != nil { ctx.ServerError("GetLFSMetaObjectByOid", err) @@ -215,7 +215,7 @@ func GetRawFileOrLFS(ctx *context.APIContext) { } defer lfsDataRc.Close() - common.ServeContentByReadSeeker(ctx.Context, ctx.Repo.TreePath, lastModified, lfsDataRc) + common.ServeContentByReadSeeker(ctx.Base, ctx.Repo.TreePath, lastModified, lfsDataRc) } func getBlobForEntry(ctx *context.APIContext) (blob *git.Blob, entry *git.TreeEntry, lastModified time.Time) { diff --git a/routers/api/v1/repo/hook_test.go b/routers/api/v1/repo/hook_test.go index 34dc990c3da49..56658b45d59b8 100644 --- a/routers/api/v1/repo/hook_test.go +++ b/routers/api/v1/repo/hook_test.go @@ -9,7 +9,6 @@ import ( "code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/webhook" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/test" "github.com/stretchr/testify/assert" @@ -18,12 +17,12 @@ import ( func TestTestHook(t *testing.T) { unittest.PrepareTestEnv(t) - ctx := test.MockContext(t, "user2/repo1/wiki/_pages") + ctx := test.MockAPIContext(t, "user2/repo1/wiki/_pages") ctx.SetParams(":id", "1") test.LoadRepo(t, ctx, 1) test.LoadRepoCommit(t, ctx) test.LoadUser(t, ctx, 2) - TestHook(&context.APIContext{Context: ctx, Org: nil}) + TestHook(ctx) assert.EqualValues(t, http.StatusNoContent, ctx.Resp.Status()) unittest.AssertExistsAndLoadBean(t, &webhook.HookTask{ diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index 95528d664d7b9..49252f7a4b49a 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -116,7 +116,7 @@ func SearchIssues(ctx *context.APIContext) { // "200": // "$ref": "#/responses/IssueList" - before, since, err := context.GetQueryBeforeSince(ctx.Context) + before, since, err := context.GetQueryBeforeSince(ctx.Base) if err != nil { ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err) return @@ -368,7 +368,7 @@ func ListIssues(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/IssueList" - before, since, err := context.GetQueryBeforeSince(ctx.Context) + before, since, err := context.GetQueryBeforeSince(ctx.Base) if err != nil { ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err) return diff --git a/routers/api/v1/repo/issue_comment.go b/routers/api/v1/repo/issue_comment.go index 6ae6063303531..7c8f30f116134 100644 --- a/routers/api/v1/repo/issue_comment.go +++ b/routers/api/v1/repo/issue_comment.go @@ -59,7 +59,7 @@ func ListIssueComments(ctx *context.APIContext) { // "200": // "$ref": "#/responses/CommentList" - before, since, err := context.GetQueryBeforeSince(ctx.Context) + before, since, err := context.GetQueryBeforeSince(ctx.Base) if err != nil { ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err) return @@ -156,7 +156,7 @@ func ListIssueCommentsAndTimeline(ctx *context.APIContext) { // "200": // "$ref": "#/responses/TimelineList" - before, since, err := context.GetQueryBeforeSince(ctx.Context) + before, since, err := context.GetQueryBeforeSince(ctx.Base) if err != nil { ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err) return @@ -259,7 +259,7 @@ func ListRepoIssueComments(ctx *context.APIContext) { // "200": // "$ref": "#/responses/CommentList" - before, since, err := context.GetQueryBeforeSince(ctx.Context) + before, since, err := context.GetQueryBeforeSince(ctx.Base) if err != nil { ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err) return diff --git a/routers/api/v1/repo/issue_tracked_time.go b/routers/api/v1/repo/issue_tracked_time.go index 16bb8cb73d6c3..1ff934950c041 100644 --- a/routers/api/v1/repo/issue_tracked_time.go +++ b/routers/api/v1/repo/issue_tracked_time.go @@ -103,7 +103,7 @@ func ListTrackedTimes(ctx *context.APIContext) { opts.UserID = user.ID } - if opts.CreatedBeforeUnix, opts.CreatedAfterUnix, err = context.GetQueryBeforeSince(ctx.Context); err != nil { + if opts.CreatedBeforeUnix, opts.CreatedAfterUnix, err = context.GetQueryBeforeSince(ctx.Base); err != nil { ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err) return } @@ -522,7 +522,7 @@ func ListTrackedTimesByRepository(ctx *context.APIContext) { } var err error - if opts.CreatedBeforeUnix, opts.CreatedAfterUnix, err = context.GetQueryBeforeSince(ctx.Context); err != nil { + if opts.CreatedBeforeUnix, opts.CreatedAfterUnix, err = context.GetQueryBeforeSince(ctx.Base); err != nil { ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err) return } @@ -596,7 +596,7 @@ func ListMyTrackedTimes(ctx *context.APIContext) { } var err error - if opts.CreatedBeforeUnix, opts.CreatedAfterUnix, err = context.GetQueryBeforeSince(ctx.Context); err != nil { + if opts.CreatedBeforeUnix, opts.CreatedAfterUnix, err = context.GetQueryBeforeSince(ctx.Base); err != nil { ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err) return } diff --git a/routers/api/v1/repo/migrate.go b/routers/api/v1/repo/migrate.go index efce39e520344..b458cd122b8a3 100644 --- a/routers/api/v1/repo/migrate.go +++ b/routers/api/v1/repo/migrate.go @@ -79,7 +79,7 @@ func Migrate(ctx *context.APIContext) { return } - if ctx.HasError() { + if ctx.HasAPIError() { ctx.Error(http.StatusUnprocessableEntity, "", ctx.GetErrMsg()) return } diff --git a/routers/api/v1/repo/repo_test.go b/routers/api/v1/repo/repo_test.go index 59c3bde81930e..e1bdea5c82bd8 100644 --- a/routers/api/v1/repo/repo_test.go +++ b/routers/api/v1/repo/repo_test.go @@ -9,7 +9,6 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" - "code.gitea.io/gitea/modules/context" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/modules/web" @@ -20,7 +19,7 @@ import ( func TestRepoEdit(t *testing.T) { unittest.PrepareTestEnv(t) - ctx := test.MockContext(t, "user2/repo1") + ctx := test.MockAPIContext(t, "user2/repo1") test.LoadRepo(t, ctx, 1) test.LoadUser(t, ctx, 2) ctx.Repo.Owner = ctx.Doer @@ -54,9 +53,8 @@ func TestRepoEdit(t *testing.T) { Archived: &archived, } - apiCtx := &context.APIContext{Context: ctx, Org: nil} - web.SetForm(apiCtx, &opts) - Edit(apiCtx) + web.SetForm(ctx, &opts) + Edit(ctx) assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ @@ -67,7 +65,7 @@ func TestRepoEdit(t *testing.T) { func TestRepoEditNameChange(t *testing.T) { unittest.PrepareTestEnv(t) - ctx := test.MockContext(t, "user2/repo1") + ctx := test.MockAPIContext(t, "user2/repo1") test.LoadRepo(t, ctx, 1) test.LoadUser(t, ctx, 2) ctx.Repo.Owner = ctx.Doer @@ -76,9 +74,8 @@ func TestRepoEditNameChange(t *testing.T) { Name: &name, } - apiCtx := &context.APIContext{Context: ctx, Org: nil} - web.SetForm(apiCtx, &opts) - Edit(apiCtx) + web.SetForm(ctx, &opts) + Edit(ctx) assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ diff --git a/routers/api/v1/repo/status.go b/routers/api/v1/repo/status.go index 5158f38e144e5..c1110ebce553c 100644 --- a/routers/api/v1/repo/status.go +++ b/routers/api/v1/repo/status.go @@ -183,7 +183,7 @@ func getCommitStatuses(ctx *context.APIContext, sha string) { ctx.Error(http.StatusBadRequest, "ref/sha not given", nil) return } - sha = utils.MustConvertToSHA1(ctx.Context, sha) + sha = utils.MustConvertToSHA1(ctx.Base, ctx.Repo, sha) repo := ctx.Repo.Repository listOptions := utils.GetListOptions(ctx) diff --git a/routers/api/v1/user/helper.go b/routers/api/v1/user/helper.go index 28f600ad92a23..4b642910b19c6 100644 --- a/routers/api/v1/user/helper.go +++ b/routers/api/v1/user/helper.go @@ -17,7 +17,7 @@ func GetUserByParamsName(ctx *context.APIContext, name string) *user_model.User if err != nil { if user_model.IsErrUserNotExist(err) { if redirectUserID, err2 := user_model.LookupUserRedirect(username); err2 == nil { - context.RedirectToUser(ctx.Context, username, redirectUserID) + context.RedirectToUser(ctx.Base, username, redirectUserID) } else { ctx.NotFound("GetUserByName", err) } diff --git a/routers/api/v1/utils/git.go b/routers/api/v1/utils/git.go index eaf0f5fd37fce..32f5c85319d46 100644 --- a/routers/api/v1/utils/git.go +++ b/routers/api/v1/utils/git.go @@ -4,6 +4,7 @@ package utils import ( + gocontext "context" "fmt" "net/http" @@ -33,7 +34,7 @@ func ResolveRefOrSha(ctx *context.APIContext, ref string) string { } } - sha = MustConvertToSHA1(ctx.Context, sha) + sha = MustConvertToSHA1(ctx, ctx.Repo, sha) if ctx.Repo.GitRepo != nil { err := ctx.Repo.GitRepo.AddLastCommitCache(ctx.Repo.Repository.GetCommitsCountCacheKey(ref, ref != sha), ctx.Repo.Repository.FullName(), sha) @@ -69,7 +70,7 @@ func searchRefCommitByType(ctx *context.APIContext, refType, filter string) (str } // ConvertToSHA1 returns a full-length SHA1 from a potential ID string -func ConvertToSHA1(ctx *context.Context, commitID string) (git.SHA1, error) { +func ConvertToSHA1(ctx gocontext.Context, repo *context.Repository, commitID string) (git.SHA1, error) { if len(commitID) == git.SHAFullLength && git.IsValidSHAPattern(commitID) { sha1, err := git.NewIDFromString(commitID) if err == nil { @@ -77,7 +78,7 @@ func ConvertToSHA1(ctx *context.Context, commitID string) (git.SHA1, error) { } } - gitRepo, closer, err := git.RepositoryFromContextOrOpen(ctx, ctx.Repo.Repository.RepoPath()) + gitRepo, closer, err := git.RepositoryFromContextOrOpen(ctx, repo.Repository.RepoPath()) if err != nil { return git.SHA1{}, fmt.Errorf("RepositoryFromContextOrOpen: %w", err) } @@ -87,8 +88,8 @@ func ConvertToSHA1(ctx *context.Context, commitID string) (git.SHA1, error) { } // MustConvertToSHA1 returns a full-length SHA1 string from a potential ID string, or returns origin input if it can't convert to SHA1 -func MustConvertToSHA1(ctx *context.Context, commitID string) string { - sha, err := ConvertToSHA1(ctx, commitID) +func MustConvertToSHA1(ctx gocontext.Context, repo *context.Repository, commitID string) string { + sha, err := ConvertToSHA1(ctx, repo, commitID) if err != nil { return commitID } diff --git a/routers/common/markup.go b/routers/common/markup.go index 3acd12721e854..5f412014d7e76 100644 --- a/routers/common/markup.go +++ b/routers/common/markup.go @@ -19,7 +19,7 @@ import ( ) // RenderMarkup renders markup text for the /markup and /markdown endpoints -func RenderMarkup(ctx *context.Context, mode, text, urlPrefix, filePath string, wiki bool) { +func RenderMarkup(ctx *context.Base, repo *context.Repository, mode, text, urlPrefix, filePath string, wiki bool) { var markupType string relativePath := "" @@ -63,11 +63,11 @@ func RenderMarkup(ctx *context.Context, mode, text, urlPrefix, filePath string, } meta := map[string]string{} - if ctx.Repo != nil && ctx.Repo.Repository != nil { + if repo != nil && repo.Repository != nil { if mode == "comment" { - meta = ctx.Repo.Repository.ComposeMetas() + meta = repo.Repository.ComposeMetas() } else { - meta = ctx.Repo.Repository.ComposeDocumentMetas() + meta = repo.Repository.ComposeDocumentMetas() } } if mode != "comment" { diff --git a/routers/common/middleware.go b/routers/common/middleware.go index c1ee9dd765af9..a25ff1ee00a5e 100644 --- a/routers/common/middleware.go +++ b/routers/common/middleware.go @@ -42,7 +42,7 @@ func ProtocolMiddlewares() (handlers []any) { return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { ctx, _, finished := process.GetManager().AddTypedContext(req.Context(), fmt.Sprintf("%s: %s", req.Method, req.RequestURI), process.RequestProcessType, true) defer finished() - next.ServeHTTP(context.NewResponse(resp), req.WithContext(cache.WithCacheContext(ctx))) + next.ServeHTTP(context.WrapResponseWriter(resp), req.WithContext(cache.WithCacheContext(ctx))) }) }) diff --git a/routers/common/serve.go b/routers/common/serve.go index 59b993328e47e..3094ee6a6ec9b 100644 --- a/routers/common/serve.go +++ b/routers/common/serve.go @@ -15,7 +15,7 @@ import ( ) // ServeBlob download a git.Blob -func ServeBlob(ctx *context.Context, blob *git.Blob, lastModified time.Time) error { +func ServeBlob(ctx *context.Base, filePath string, blob *git.Blob, lastModified time.Time) error { if httpcache.HandleGenericETagTimeCache(ctx.Req, ctx.Resp, `"`+blob.ID.String()+`"`, lastModified) { return nil } @@ -30,14 +30,14 @@ func ServeBlob(ctx *context.Context, blob *git.Blob, lastModified time.Time) err } }() - httplib.ServeContentByReader(ctx.Req, ctx.Resp, ctx.Repo.TreePath, blob.Size(), dataRc) + httplib.ServeContentByReader(ctx.Req, ctx.Resp, filePath, blob.Size(), dataRc) return nil } -func ServeContentByReader(ctx *context.Context, filePath string, size int64, reader io.Reader) { +func ServeContentByReader(ctx *context.Base, filePath string, size int64, reader io.Reader) { httplib.ServeContentByReader(ctx.Req, ctx.Resp, filePath, size, reader) } -func ServeContentByReadSeeker(ctx *context.Context, filePath string, modTime time.Time, reader io.ReadSeeker) { +func ServeContentByReadSeeker(ctx *context.Base, filePath string, modTime time.Time, reader io.ReadSeeker) { httplib.ServeContentByReadSeeker(ctx.Req, ctx.Resp, filePath, modTime, reader) } diff --git a/routers/init.go b/routers/init.go index 087d8c2915bb9..5737ef3dc06a4 100644 --- a/routers/init.go +++ b/routers/init.go @@ -198,7 +198,7 @@ func NormalRoutes(ctx context.Context) *web.Route { // In Github, it uses ACTIONS_RUNTIME_URL=https://pipelines.actions.githubusercontent.com/fLgcSHkPGySXeIFrg8W8OBSfeg3b5Fls1A1CwX566g8PayEGlg/ // TODO: this prefix should be generated with a token string with runner ? prefix = "/api/actions_pipeline" - r.Mount(prefix, actions_router.ArtifactsRoutes(ctx, prefix)) + r.Mount(prefix, actions_router.ArtifactsRoutes(prefix)) } return r diff --git a/routers/install/install.go b/routers/install/install.go index 714ddd5548454..89b91a5a483a5 100644 --- a/routers/install/install.go +++ b/routers/install/install.go @@ -58,15 +58,14 @@ func Contexter() func(next http.Handler) http.Handler { dbTypeNames := getSupportedDbTypeNames() return func(next http.Handler) http.Handler { return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { + base, baseCleanUp := context.NewBaseContext(resp, req) ctx := context.Context{ - Resp: context.NewResponse(resp), + Base: base, Flash: &middleware.Flash{}, - Locale: middleware.Locale(resp, req), Render: rnd, - Data: middleware.GetContextData(req.Context()), Session: session.GetSession(req), } - defer ctx.Close() + defer baseCleanUp() ctx.Data.MergeFrom(middleware.CommonTemplateContextData()) ctx.Data.MergeFrom(middleware.ContextData{ @@ -78,7 +77,6 @@ func Contexter() func(next http.Handler) http.Handler { "PasswordHashAlgorithms": hash.RecommendedHashAlgorithms, }) - ctx.Req = context.WithContext(req, &ctx) next.ServeHTTP(resp, ctx.Req) }) } @@ -249,15 +247,8 @@ func SubmitInstall(ctx *context.Context) { ctx.Data["CurDbType"] = form.DbType if ctx.HasError() { - if ctx.HasValue("Err_SMTPUser") { - ctx.Data["Err_SMTP"] = true - } - if ctx.HasValue("Err_AdminName") || - ctx.HasValue("Err_AdminPasswd") || - ctx.HasValue("Err_AdminEmail") { - ctx.Data["Err_Admin"] = true - } - + ctx.Data["Err_SMTP"] = ctx.Data["Err_SMTPUser"] != nil + ctx.Data["Err_Admin"] = ctx.Data["Err_AdminName"] != nil || ctx.Data["Err_AdminPasswd"] != nil || ctx.Data["Err_AdminEmail"] != nil ctx.HTML(http.StatusOK, tplInstall) return } diff --git a/routers/web/misc/markup.go b/routers/web/misc/markup.go index 169037894530c..c91da9a7f1028 100644 --- a/routers/web/misc/markup.go +++ b/routers/web/misc/markup.go @@ -5,8 +5,6 @@ package misc import ( - "net/http" - "code.gitea.io/gitea/modules/context" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" @@ -16,11 +14,5 @@ import ( // Markup render markup document to HTML func Markup(ctx *context.Context) { form := web.GetForm(ctx).(*api.MarkupOption) - - if ctx.HasAPIError() { - ctx.Error(http.StatusUnprocessableEntity, "", ctx.GetErrMsg()) - return - } - - common.RenderMarkup(ctx, form.Mode, form.Text, form.Context, form.FilePath, form.Wiki) + common.RenderMarkup(ctx.Base, ctx.Repo, form.Mode, form.Text, form.Context, form.FilePath, form.Wiki) } diff --git a/routers/web/repo/attachment.go b/routers/web/repo/attachment.go index c46ec29841a73..fb95e63ecf403 100644 --- a/routers/web/repo/attachment.go +++ b/routers/web/repo/attachment.go @@ -153,7 +153,7 @@ func ServeAttachment(ctx *context.Context, uuid string) { } defer fr.Close() - common.ServeContentByReadSeeker(ctx, attach.Name, attach.CreatedUnix.AsTime(), fr) + common.ServeContentByReadSeeker(ctx.Base, attach.Name, attach.CreatedUnix.AsTime(), fr) } // GetAttachment serve attachments diff --git a/routers/web/repo/download.go b/routers/web/repo/download.go index 1c87f9bed73cd..a498180f35ab3 100644 --- a/routers/web/repo/download.go +++ b/routers/web/repo/download.go @@ -47,7 +47,7 @@ func ServeBlobOrLFS(ctx *context.Context, blob *git.Blob, lastModified time.Time log.Error("ServeBlobOrLFS: Close: %v", err) } closed = true - return common.ServeBlob(ctx, blob, lastModified) + return common.ServeBlob(ctx.Base, ctx.Repo.TreePath, blob, lastModified) } if httpcache.HandleGenericETagCache(ctx.Req, ctx.Resp, `"`+pointer.Oid+`"`) { return nil @@ -71,7 +71,7 @@ func ServeBlobOrLFS(ctx *context.Context, blob *git.Blob, lastModified time.Time log.Error("ServeBlobOrLFS: Close: %v", err) } }() - common.ServeContentByReadSeeker(ctx, ctx.Repo.TreePath, lastModified, lfsDataRc) + common.ServeContentByReadSeeker(ctx.Base, ctx.Repo.TreePath, lastModified, lfsDataRc) return nil } if err = dataRc.Close(); err != nil { @@ -79,7 +79,7 @@ func ServeBlobOrLFS(ctx *context.Context, blob *git.Blob, lastModified time.Time } closed = true - return common.ServeBlob(ctx, blob, lastModified) + return common.ServeBlob(ctx.Base, ctx.Repo.TreePath, blob, lastModified) } func getBlobForEntry(ctx *context.Context) (blob *git.Blob, lastModified time.Time) { @@ -120,7 +120,7 @@ func SingleDownload(ctx *context.Context) { return } - if err := common.ServeBlob(ctx, blob, lastModified); err != nil { + if err := common.ServeBlob(ctx.Base, ctx.Repo.TreePath, blob, lastModified); err != nil { ctx.ServerError("ServeBlob", err) } } @@ -148,7 +148,7 @@ func DownloadByID(ctx *context.Context) { } return } - if err = common.ServeBlob(ctx, blob, time.Time{}); err != nil { + if err = common.ServeBlob(ctx.Base, ctx.Repo.TreePath, blob, time.Time{}); err != nil { ctx.ServerError("ServeBlob", err) } } diff --git a/routers/web/repo/http.go b/routers/web/repo/http.go index 4e45a9b6e21df..b6ebd25915626 100644 --- a/routers/web/repo/http.go +++ b/routers/web/repo/http.go @@ -109,7 +109,7 @@ func httpBase(ctx *context.Context) (h *serviceHandler) { if err != nil { if repo_model.IsErrRepoNotExist(err) { if redirectRepoID, err := repo_model.LookupRedirect(owner.ID, reponame); err == nil { - context.RedirectToRepo(ctx, redirectRepoID) + context.RedirectToRepo(ctx.Base, redirectRepoID) return } repoExist = false diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index 88d2a97a7ad9c..7a0dc9940beb0 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -2344,7 +2344,7 @@ func UpdatePullReviewRequest(ctx *context.Context) { // SearchIssues searches for issues across the repositories that the user has access to func SearchIssues(ctx *context.Context) { - before, since, err := context.GetQueryBeforeSince(ctx) + before, since, err := context.GetQueryBeforeSince(ctx.Base) if err != nil { ctx.Error(http.StatusUnprocessableEntity, err.Error()) return @@ -2545,7 +2545,7 @@ func getUserIDForFilter(ctx *context.Context, queryName string) int64 { // ListIssues list the issues of a repository func ListIssues(ctx *context.Context) { - before, since, err := context.GetQueryBeforeSince(ctx) + before, since, err := context.GetQueryBeforeSince(ctx.Base) if err != nil { ctx.Error(http.StatusUnprocessableEntity, err.Error()) return diff --git a/routers/web/repo/wiki.go b/routers/web/repo/wiki.go index a335c114be695..115418887d30a 100644 --- a/routers/web/repo/wiki.go +++ b/routers/web/repo/wiki.go @@ -671,7 +671,7 @@ func WikiRaw(ctx *context.Context) { } if entry != nil { - if err = common.ServeBlob(ctx, entry.Blob(), time.Time{}); err != nil { + if err = common.ServeBlob(ctx.Base, ctx.Repo.TreePath, entry.Blob(), time.Time{}); err != nil { ctx.ServerError("ServeBlob", err) } return diff --git a/services/auth/middleware.go b/services/auth/middleware.go index 3b2f883d009c1..d1955a4c90010 100644 --- a/services/auth/middleware.go +++ b/services/auth/middleware.go @@ -8,6 +8,7 @@ import ( "strings" "code.gitea.io/gitea/models/auth" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" @@ -17,11 +18,15 @@ import ( // Auth is a middleware to authenticate a web user func Auth(authMethod Method) func(*context.Context) { return func(ctx *context.Context) { - if err := authShared(ctx, authMethod); err != nil { + ar, err := authShared(ctx.Base, ctx.Session, authMethod) + if err != nil { log.Error("Failed to verify user: %v", err) ctx.Error(http.StatusUnauthorized, "Verify") return } + ctx.Doer = ar.Doer + ctx.IsSigned = ar.Doer != nil + ctx.IsBasicAuth = ar.IsBasicAuth if ctx.Doer == nil { // ensure the session uid is deleted _ = ctx.Session.Delete("uid") @@ -32,32 +37,41 @@ func Auth(authMethod Method) func(*context.Context) { // APIAuth is a middleware to authenticate an api user func APIAuth(authMethod Method) func(*context.APIContext) { return func(ctx *context.APIContext) { - if err := authShared(ctx.Context, authMethod); err != nil { + ar, err := authShared(ctx.Base, nil, authMethod) + if err != nil { ctx.Error(http.StatusUnauthorized, "APIAuth", err) + return } + ctx.Doer = ar.Doer + ctx.IsSigned = ar.Doer != nil + ctx.IsBasicAuth = ar.IsBasicAuth } } -func authShared(ctx *context.Context, authMethod Method) error { - var err error - ctx.Doer, err = authMethod.Verify(ctx.Req, ctx.Resp, ctx, ctx.Session) +type authResult struct { + Doer *user_model.User + IsBasicAuth bool +} + +func authShared(ctx *context.Base, sessionStore SessionStore, authMethod Method) (ar authResult, err error) { + ar.Doer, err = authMethod.Verify(ctx.Req, ctx.Resp, ctx, sessionStore) if err != nil { - return err + return ar, err } - if ctx.Doer != nil { - if ctx.Locale.Language() != ctx.Doer.Language { + if ar.Doer != nil { + if ctx.Locale.Language() != ar.Doer.Language { ctx.Locale = middleware.Locale(ctx.Resp, ctx.Req) } - ctx.IsBasicAuth = ctx.Data["AuthedMethod"].(string) == BasicMethodName - ctx.IsSigned = true - ctx.Data["IsSigned"] = ctx.IsSigned - ctx.Data[middleware.ContextDataKeySignedUser] = ctx.Doer - ctx.Data["SignedUserID"] = ctx.Doer.ID - ctx.Data["IsAdmin"] = ctx.Doer.IsAdmin + ar.IsBasicAuth = ctx.Data["AuthedMethod"].(string) == BasicMethodName + + ctx.Data["IsSigned"] = true + ctx.Data[middleware.ContextDataKeySignedUser] = ar.Doer + ctx.Data["SignedUserID"] = ar.Doer.ID + ctx.Data["IsAdmin"] = ar.Doer.IsAdmin } else { ctx.Data["SignedUserID"] = int64(0) } - return nil + return ar, nil } // VerifyOptions contains required or check options @@ -68,7 +82,7 @@ type VerifyOptions struct { DisableCSRF bool } -// Checks authentication according to options +// VerifyAuthWithOptions checks authentication according to options func VerifyAuthWithOptions(options *VerifyOptions) func(ctx *context.Context) { return func(ctx *context.Context) { // Check prohibit login users. @@ -153,7 +167,7 @@ func VerifyAuthWithOptions(options *VerifyOptions) func(ctx *context.Context) { } } -// Checks authentication according to options +// VerifyAuthWithOptionsAPI checks authentication according to options func VerifyAuthWithOptionsAPI(options *VerifyOptions) func(ctx *context.APIContext) { return func(ctx *context.APIContext) { // Check prohibit login users. @@ -197,7 +211,9 @@ func VerifyAuthWithOptionsAPI(options *VerifyOptions) func(ctx *context.APIConte return } else if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm { ctx.Data["Title"] = ctx.Tr("auth.active_your_account") - ctx.HTML(http.StatusOK, "user/auth/activate") + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "This account is not activated.", + }) return } if ctx.IsSigned && ctx.IsBasicAuth { diff --git a/services/context/user.go b/services/context/user.go index c713667bca75a..4e74aa50bd1e2 100644 --- a/services/context/user.go +++ b/services/context/user.go @@ -15,7 +15,7 @@ import ( // UserAssignmentWeb returns a middleware to handle context-user assignment for web routes func UserAssignmentWeb() func(ctx *context.Context) { return func(ctx *context.Context) { - userAssignment(ctx, func(status int, title string, obj interface{}) { + errorFn := func(status int, title string, obj interface{}) { err, ok := obj.(error) if !ok { err = fmt.Errorf("%s", obj) @@ -25,7 +25,8 @@ func UserAssignmentWeb() func(ctx *context.Context) { } else { ctx.ServerError(title, err) } - }) + } + ctx.ContextUser = userAssignment(ctx.Base, ctx.Doer, errorFn) } } @@ -53,18 +54,18 @@ func UserIDAssignmentAPI() func(ctx *context.APIContext) { // UserAssignmentAPI returns a middleware to handle context-user assignment for api routes func UserAssignmentAPI() func(ctx *context.APIContext) { return func(ctx *context.APIContext) { - userAssignment(ctx.Context, ctx.Error) + ctx.ContextUser = userAssignment(ctx.Base, ctx.Doer, ctx.Error) } } -func userAssignment(ctx *context.Context, errCb func(int, string, interface{})) { +func userAssignment(ctx *context.Base, doer *user_model.User, errCb func(int, string, interface{})) (contextUser *user_model.User) { username := ctx.Params(":username") - if ctx.IsSigned && ctx.Doer.LowerName == strings.ToLower(username) { - ctx.ContextUser = ctx.Doer + if doer != nil && doer.LowerName == strings.ToLower(username) { + contextUser = doer } else { var err error - ctx.ContextUser, err = user_model.GetUserByName(ctx, username) + contextUser, err = user_model.GetUserByName(ctx, username) if err != nil { if user_model.IsErrUserNotExist(err) { if redirectUserID, err := user_model.LookupUserRedirect(username); err == nil { @@ -79,4 +80,5 @@ func userAssignment(ctx *context.Context, errCb func(int, string, interface{})) } } } + return contextUser } diff --git a/services/forms/admin.go b/services/forms/admin.go index a749f863f3dc8..4b3cacc606f7d 100644 --- a/services/forms/admin.go +++ b/services/forms/admin.go @@ -27,7 +27,7 @@ type AdminCreateUserForm struct { // Validate validates form fields func (f *AdminCreateUserForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -55,7 +55,7 @@ type AdminEditUserForm struct { // Validate validates form fields func (f *AdminEditUserForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -67,6 +67,6 @@ type AdminDashboardForm struct { // Validate validates form fields func (f *AdminDashboardForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } diff --git a/services/forms/auth_form.go b/services/forms/auth_form.go index 5625aa1e2ed48..25acbbb99e877 100644 --- a/services/forms/auth_form.go +++ b/services/forms/auth_form.go @@ -86,6 +86,6 @@ type AuthenticationForm struct { // Validate validates fields func (f *AuthenticationForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } diff --git a/services/forms/org.go b/services/forms/org.go index d7535313713e3..c333bead316dd 100644 --- a/services/forms/org.go +++ b/services/forms/org.go @@ -30,7 +30,7 @@ type CreateOrgForm struct { // Validate validates the fields func (f *CreateOrgForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -48,7 +48,7 @@ type UpdateOrgSettingForm struct { // Validate validates the fields func (f *UpdateOrgSettingForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -70,6 +70,6 @@ type CreateTeamForm struct { // Validate validates the fields func (f *CreateTeamForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } diff --git a/services/forms/package_form.go b/services/forms/package_form.go index dfec98fff455b..cf8abfb8fbc93 100644 --- a/services/forms/package_form.go +++ b/services/forms/package_form.go @@ -25,6 +25,6 @@ type PackageCleanupRuleForm struct { } func (f *PackageCleanupRuleForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } diff --git a/services/forms/repo_branch_form.go b/services/forms/repo_branch_form.go index bf1183fc43411..5deb0ae463a03 100644 --- a/services/forms/repo_branch_form.go +++ b/services/forms/repo_branch_form.go @@ -21,7 +21,7 @@ type NewBranchForm struct { // Validate validates the fields func (f *NewBranchForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -33,6 +33,6 @@ type RenameBranchForm struct { // Validate validates the fields func (f *RenameBranchForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index d705ecad3f7db..cacfb64b172fe 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -54,7 +54,7 @@ type CreateRepoForm struct { // Validate validates the fields func (f *CreateRepoForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -87,7 +87,7 @@ type MigrateRepoForm struct { // Validate validates the fields func (f *MigrateRepoForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -176,7 +176,7 @@ type RepoSettingForm struct { // Validate validates the fields func (f *RepoSettingForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -215,7 +215,7 @@ type ProtectBranchForm struct { // Validate validates the fields func (f *ProtectBranchForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -280,7 +280,7 @@ type NewWebhookForm struct { // Validate validates the fields func (f *NewWebhookForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -294,7 +294,7 @@ type NewGogshookForm struct { // Validate validates the fields func (f *NewGogshookForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -310,7 +310,7 @@ type NewSlackHookForm struct { // Validate validates the fields func (f *NewSlackHookForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) if !webhook.IsValidSlackChannel(strings.TrimSpace(f.Channel)) { errs = append(errs, binding.Error{ FieldNames: []string{"Channel"}, @@ -331,7 +331,7 @@ type NewDiscordHookForm struct { // Validate validates the fields func (f *NewDiscordHookForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -343,7 +343,7 @@ type NewDingtalkHookForm struct { // Validate validates the fields func (f *NewDingtalkHookForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -356,7 +356,7 @@ type NewTelegramHookForm struct { // Validate validates the fields func (f *NewTelegramHookForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -370,7 +370,7 @@ type NewMatrixHookForm struct { // Validate validates the fields func (f *NewMatrixHookForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -382,7 +382,7 @@ type NewMSTeamsHookForm struct { // Validate validates the fields func (f *NewMSTeamsHookForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -394,7 +394,7 @@ type NewFeishuHookForm struct { // Validate validates the fields func (f *NewFeishuHookForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -406,7 +406,7 @@ type NewWechatWorkHookForm struct { // Validate validates the fields func (f *NewWechatWorkHookForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -420,7 +420,7 @@ type NewPackagistHookForm struct { // Validate validates the fields func (f *NewPackagistHookForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -447,7 +447,7 @@ type CreateIssueForm struct { // Validate validates the fields func (f *CreateIssueForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -460,7 +460,7 @@ type CreateCommentForm struct { // Validate validates the fields func (f *CreateCommentForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -471,7 +471,7 @@ type ReactionForm struct { // Validate validates the fields func (f *ReactionForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -482,7 +482,7 @@ type IssueLockForm struct { // Validate validates the fields func (i *IssueLockForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, i, ctx.Locale) } @@ -550,7 +550,7 @@ type CreateMilestoneForm struct { // Validate validates the fields func (f *CreateMilestoneForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -572,7 +572,7 @@ type CreateLabelForm struct { // Validate validates the fields func (f *CreateLabelForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -583,7 +583,7 @@ type InitializeLabelsForm struct { // Validate validates the fields func (f *InitializeLabelsForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -611,7 +611,7 @@ type MergePullRequestForm struct { // Validate validates the fields func (f *MergePullRequestForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -629,7 +629,7 @@ type CodeCommentForm struct { // Validate validates the fields func (f *CodeCommentForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -643,7 +643,7 @@ type SubmitReviewForm struct { // Validate validates the fields func (f *SubmitReviewForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -704,7 +704,7 @@ type NewReleaseForm struct { // Validate validates the fields func (f *NewReleaseForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -719,7 +719,7 @@ type EditReleaseForm struct { // Validate validates the fields func (f *EditReleaseForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -740,7 +740,7 @@ type NewWikiForm struct { // Validate validates the fields // FIXME: use code generation to generate this method. func (f *NewWikiForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -765,7 +765,7 @@ type EditRepoFileForm struct { // Validate validates the fields func (f *EditRepoFileForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -776,7 +776,7 @@ type EditPreviewDiffForm struct { // Validate validates the fields func (f *EditPreviewDiffForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -800,7 +800,7 @@ type CherryPickForm struct { // Validate validates the fields func (f *CherryPickForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -825,7 +825,7 @@ type UploadRepoFileForm struct { // Validate validates the fields func (f *UploadRepoFileForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -836,7 +836,7 @@ type RemoveUploadFileForm struct { // Validate validates the fields func (f *RemoveUploadFileForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -859,7 +859,7 @@ type DeleteRepoFileForm struct { // Validate validates the fields func (f *DeleteRepoFileForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -878,7 +878,7 @@ type AddTimeManuallyForm struct { // Validate validates the fields func (f *AddTimeManuallyForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -894,6 +894,6 @@ type DeadlineForm struct { // Validate validates the fields func (f *DeadlineForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } diff --git a/services/forms/repo_tag_form.go b/services/forms/repo_tag_form.go index 1209d2346f466..4dd99f9e32fee 100644 --- a/services/forms/repo_tag_form.go +++ b/services/forms/repo_tag_form.go @@ -21,6 +21,6 @@ type ProtectTagForm struct { // Validate validates the fields func (f *ProtectTagForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } diff --git a/services/forms/runner.go b/services/forms/runner.go index 906306034628f..22dea49e3129c 100644 --- a/services/forms/runner.go +++ b/services/forms/runner.go @@ -20,6 +20,6 @@ type EditRunnerForm struct { // Validate validates form fields func (f *EditRunnerForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } diff --git a/services/forms/user_form.go b/services/forms/user_form.go index 285bc398b26c5..fa8129bf85d02 100644 --- a/services/forms/user_form.go +++ b/services/forms/user_form.go @@ -78,7 +78,7 @@ type InstallForm struct { // Validate validates the fields func (f *InstallForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -99,7 +99,7 @@ type RegisterForm struct { // Validate validates the fields func (f *RegisterForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -148,7 +148,7 @@ type MustChangePasswordForm struct { // Validate validates the fields func (f *MustChangePasswordForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -162,7 +162,7 @@ type SignInForm struct { // Validate validates the fields func (f *SignInForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -182,7 +182,7 @@ type AuthorizationForm struct { // Validate validates the fields func (f *AuthorizationForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -197,7 +197,7 @@ type GrantApplicationForm struct { // Validate validates the fields func (f *GrantApplicationForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -216,7 +216,7 @@ type AccessTokenForm struct { // Validate validates the fields func (f *AccessTokenForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -227,7 +227,7 @@ type IntrospectTokenForm struct { // Validate validates the fields func (f *IntrospectTokenForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -252,7 +252,7 @@ type UpdateProfileForm struct { // Validate validates the fields func (f *UpdateProfileForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -263,7 +263,7 @@ type UpdateLanguageForm struct { // Validate validates the fields func (f *UpdateLanguageForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -283,7 +283,7 @@ type AvatarForm struct { // Validate validates the fields func (f *AvatarForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -294,7 +294,7 @@ type AddEmailForm struct { // Validate validates the fields func (f *AddEmailForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -305,7 +305,7 @@ type UpdateThemeForm struct { // Validate validates the field func (f *UpdateThemeForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -332,7 +332,7 @@ type ChangePasswordForm struct { // Validate validates the fields func (f *ChangePasswordForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -343,7 +343,7 @@ type AddOpenIDForm struct { // Validate validates the fields func (f *AddOpenIDForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -360,7 +360,7 @@ type AddKeyForm struct { // Validate validates the fields func (f *AddKeyForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -372,7 +372,7 @@ type AddSecretForm struct { // Validate validates the fields func (f *AddSecretForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -384,7 +384,7 @@ type NewAccessTokenForm struct { // Validate validates the fields func (f *NewAccessTokenForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -403,7 +403,7 @@ type EditOAuth2ApplicationForm struct { // Validate validates the fields func (f *EditOAuth2ApplicationForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -414,7 +414,7 @@ type TwoFactorAuthForm struct { // Validate validates the fields func (f *TwoFactorAuthForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -425,7 +425,7 @@ type TwoFactorScratchAuthForm struct { // Validate validates the fields func (f *TwoFactorScratchAuthForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -436,7 +436,7 @@ type WebauthnRegistrationForm struct { // Validate validates the fields func (f *WebauthnRegistrationForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -447,7 +447,7 @@ type WebauthnDeleteForm struct { // Validate validates the fields func (f *WebauthnDeleteForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -459,6 +459,6 @@ type PackageSettingForm struct { // Validate validates the fields func (f *PackageSettingForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } diff --git a/services/forms/user_form_auth_openid.go b/services/forms/user_form_auth_openid.go index f95eb98405a5c..d8137a8d134fe 100644 --- a/services/forms/user_form_auth_openid.go +++ b/services/forms/user_form_auth_openid.go @@ -20,7 +20,7 @@ type SignInOpenIDForm struct { // Validate validates the fields func (f *SignInOpenIDForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -32,7 +32,7 @@ type SignUpOpenIDForm struct { // Validate validates the fields func (f *SignUpOpenIDForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } @@ -44,6 +44,6 @@ type ConnectOpenIDForm struct { // Validate validates the fields func (f *ConnectOpenIDForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { - ctx := context.GetContext(req) + ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } diff --git a/services/markup/processorhelper_test.go b/services/markup/processorhelper_test.go index 6c9c1c27e72fb..2f48e03b22bf0 100644 --- a/services/markup/processorhelper_test.go +++ b/services/markup/processorhelper_test.go @@ -6,6 +6,7 @@ package markup import ( "context" "net/http" + "net/http/httptest" "testing" "code.gitea.io/gitea/models/db" @@ -36,12 +37,12 @@ func TestProcessorHelper(t *testing.T) { assert.False(t, ProcessorHelper().IsUsernameMentionable(context.Background(), userNoSuch)) // when using web context, use user.IsUserVisibleToViewer to check - var err error - giteaCtx := &gitea_context.Context{} - giteaCtx.Req, err = http.NewRequest("GET", "/", nil) + req, err := http.NewRequest("GET", "/", nil) assert.NoError(t, err) + base, baseCleanUp := gitea_context.NewBaseContext(httptest.NewRecorder(), req) + defer baseCleanUp() + giteaCtx := &gitea_context.Context{Base: base} - giteaCtx.Doer = nil assert.True(t, ProcessorHelper().IsUsernameMentionable(giteaCtx, userPublic)) assert.False(t, ProcessorHelper().IsUsernameMentionable(giteaCtx, userPrivate)) From 1dfaf83798e1ea4b994fb13924fffe403f8e2e11 Mon Sep 17 00:00:00 2001 From: Yevhen Pavlov Date: Sun, 21 May 2023 05:54:28 +0300 Subject: [PATCH 15/27] Return `404` in the API if the requested webhooks were not found (#24823) Should resolve first point of the issue https://github.com/go-gitea/gitea/issues/24574 --- routers/api/v1/admin/hooks.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/routers/api/v1/admin/hooks.go b/routers/api/v1/admin/hooks.go index 6a08aa5125de1..8a095a7defa2a 100644 --- a/routers/api/v1/admin/hooks.go +++ b/routers/api/v1/admin/hooks.go @@ -4,6 +4,7 @@ package admin import ( + "errors" "net/http" "code.gitea.io/gitea/models/webhook" @@ -74,7 +75,11 @@ func GetHook(ctx *context.APIContext) { hookID := ctx.ParamsInt64(":id") hook, err := webhook.GetSystemOrDefaultWebhook(ctx, hookID) if err != nil { - ctx.Error(http.StatusInternalServerError, "GetSystemOrDefaultWebhook", err) + if errors.Is(err, util.ErrNotExist) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetSystemOrDefaultWebhook", err) + } return } h, err := webhook_service.ToHook("/admin/", hook) @@ -160,7 +165,7 @@ func DeleteHook(ctx *context.APIContext) { hookID := ctx.ParamsInt64(":id") if err := webhook.DeleteDefaultSystemWebhook(ctx, hookID); err != nil { - if webhook.IsErrWebhookNotExist(err) { + if errors.Is(err, util.ErrNotExist) { ctx.NotFound() } else { ctx.Error(http.StatusInternalServerError, "DeleteDefaultSystemWebhook", err) From edd8ea0b0d07f7fee0f8cf506975c8f3bec690ca Mon Sep 17 00:00:00 2001 From: Yarden Shoham Date: Sun, 21 May 2023 12:03:20 +0300 Subject: [PATCH 16/27] Fix topics deleted via API not being deleted in org page (#24825) The topics are saved in the `repo_topic` table. They are also saved directly in the `repository` table. Before this PR, only `AddTopic` and `SaveTopics` made sure the `topics` field in the `repository` table was synced with the `repo_topic` table. This PR makes sure `GenerateTopics` and `DeleteTopic` also sync the `topics` in the repository table. `RemoveTopicsFromRepo` doesn't need to sync the data as it is only used to delete a repository. Fixes #24820 --- models/repo/topic.go | 45 ++++++++++++++++++++++++-------------------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/models/repo/topic.go b/models/repo/topic.go index 88fe532be9a70..ec3de869d5be4 100644 --- a/models/repo/topic.go +++ b/models/repo/topic.go @@ -253,16 +253,7 @@ func AddTopic(repoID int64, topicName string) (*Topic, error) { return nil, err } - topicNames := make([]string, 0, 25) - if err := sess.Select("name").Table("topic"). - Join("INNER", "repo_topic", "repo_topic.topic_id = topic.id"). - Where("repo_topic.repo_id = ?", repoID).Desc("topic.repo_count").Find(&topicNames); err != nil { - return nil, err - } - - if _, err := sess.ID(repoID).Cols("topics").Update(&Repository{ - Topics: topicNames, - }); err != nil { + if err = syncTopicsInRepository(sess, repoID); err != nil { return nil, err } @@ -281,6 +272,11 @@ func DeleteTopic(repoID int64, topicName string) (*Topic, error) { } err = removeTopicFromRepo(db.DefaultContext, repoID, topic) + if err != nil { + return nil, err + } + + err = syncTopicsInRepository(db.GetEngine(db.DefaultContext), repoID) return topic, err } @@ -347,16 +343,7 @@ func SaveTopics(repoID int64, topicNames ...string) error { } } - topicNames = make([]string, 0, 25) - if err := sess.Table("topic").Cols("name"). - Join("INNER", "repo_topic", "repo_topic.topic_id = topic.id"). - Where("repo_topic.repo_id = ?", repoID).Desc("topic.repo_count").Find(&topicNames); err != nil { - return err - } - - if _, err := sess.ID(repoID).Cols("topics").Update(&Repository{ - Topics: topicNames, - }); err != nil { + if err := syncTopicsInRepository(sess, repoID); err != nil { return err } @@ -370,5 +357,23 @@ func GenerateTopics(ctx context.Context, templateRepo, generateRepo *Repository) return err } } + + return syncTopicsInRepository(db.GetEngine(ctx), generateRepo.ID) +} + +// syncTopicsInRepository makes sure topics in the topics table are copied into the topics field of the repository +func syncTopicsInRepository(sess db.Engine, repoID int64) error { + topicNames := make([]string, 0, 25) + if err := sess.Table("topic").Cols("name"). + Join("INNER", "repo_topic", "repo_topic.topic_id = topic.id"). + Where("repo_topic.repo_id = ?", repoID).Desc("topic.repo_count").Find(&topicNames); err != nil { + return err + } + + if _, err := sess.ID(repoID).Cols("topics").Update(&Repository{ + Topics: topicNames, + }); err != nil { + return err + } return nil } From 64f6a5d113da0d5d187752c9398d6e8d22d24b79 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Sun, 21 May 2023 20:48:28 +0800 Subject: [PATCH 17/27] Use `CommentList` instead of `[]*Comment` (#24828) As title. --- models/issues/comment.go | 2 +- models/issues/comment_code.go | 4 ++-- models/issues/issue.go | 4 ++-- routers/api/v1/repo/issue_comment.go | 16 ++++++++-------- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/models/issues/comment.go b/models/issues/comment.go index c7e815c8b6fca..bf2bbfa4145b2 100644 --- a/models/issues/comment.go +++ b/models/issues/comment.go @@ -1048,7 +1048,7 @@ func (opts *FindCommentsOptions) ToConds() builder.Cond { } // FindComments returns all comments according options -func FindComments(ctx context.Context, opts *FindCommentsOptions) ([]*Comment, error) { +func FindComments(ctx context.Context, opts *FindCommentsOptions) (CommentList, error) { comments := make([]*Comment, 0, 10) sess := db.GetEngine(ctx).Where(opts.ToConds()) if opts.RepoID > 0 { diff --git a/models/issues/comment_code.go b/models/issues/comment_code.go index 8475da1a8c002..304ac4569f86e 100644 --- a/models/issues/comment_code.go +++ b/models/issues/comment_code.go @@ -48,7 +48,7 @@ func fetchCodeCommentsByReview(ctx context.Context, issue *Issue, currentUser *u } func findCodeComments(ctx context.Context, opts FindCommentsOptions, issue *Issue, currentUser *user_model.User, review *Review) ([]*Comment, error) { - var comments []*Comment + var comments CommentList if review == nil { review = &Review{ID: 0} } @@ -68,7 +68,7 @@ func findCodeComments(ctx context.Context, opts FindCommentsOptions, issue *Issu return nil, err } - if err := CommentList(comments).LoadPosters(ctx); err != nil { + if err := comments.LoadPosters(ctx); err != nil { return nil, err } diff --git a/models/issues/issue.go b/models/issues/issue.go index bf41a7ec28407..fc046d273c986 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -124,7 +124,7 @@ type Issue struct { ClosedUnix timeutil.TimeStamp `xorm:"INDEX"` Attachments []*repo_model.Attachment `xorm:"-"` - Comments []*Comment `xorm:"-"` + Comments CommentList `xorm:"-"` Reactions ReactionList `xorm:"-"` TotalTrackedTime int64 `xorm:"-"` Assignees []*user_model.User `xorm:"-"` @@ -353,7 +353,7 @@ func (issue *Issue) LoadAttributes(ctx context.Context) (err error) { return err } - if err = CommentList(issue.Comments).loadAttributes(ctx); err != nil { + if err = issue.Comments.loadAttributes(ctx); err != nil { return err } if issue.IsTimetrackerEnabled(ctx) { diff --git a/routers/api/v1/repo/issue_comment.go b/routers/api/v1/repo/issue_comment.go index 7c8f30f116134..5616e255ad8e4 100644 --- a/routers/api/v1/repo/issue_comment.go +++ b/routers/api/v1/repo/issue_comment.go @@ -90,12 +90,12 @@ func ListIssueComments(ctx *context.APIContext) { return } - if err := issues_model.CommentList(comments).LoadPosters(ctx); err != nil { + if err := comments.LoadPosters(ctx); err != nil { ctx.Error(http.StatusInternalServerError, "LoadPosters", err) return } - if err := issues_model.CommentList(comments).LoadAttachments(ctx); err != nil { + if err := comments.LoadAttachments(ctx); err != nil { ctx.Error(http.StatusInternalServerError, "LoadAttachments", err) return } @@ -182,7 +182,7 @@ func ListIssueCommentsAndTimeline(ctx *context.APIContext) { return } - if err := issues_model.CommentList(comments).LoadPosters(ctx); err != nil { + if err := comments.LoadPosters(ctx); err != nil { ctx.Error(http.StatusInternalServerError, "LoadPosters", err) return } @@ -285,25 +285,25 @@ func ListRepoIssueComments(ctx *context.APIContext) { return } - if err = issues_model.CommentList(comments).LoadPosters(ctx); err != nil { + if err = comments.LoadPosters(ctx); err != nil { ctx.Error(http.StatusInternalServerError, "LoadPosters", err) return } apiComments := make([]*api.Comment, len(comments)) - if err := issues_model.CommentList(comments).LoadIssues(ctx); err != nil { + if err := comments.LoadIssues(ctx); err != nil { ctx.Error(http.StatusInternalServerError, "LoadIssues", err) return } - if err := issues_model.CommentList(comments).LoadPosters(ctx); err != nil { + if err := comments.LoadPosters(ctx); err != nil { ctx.Error(http.StatusInternalServerError, "LoadPosters", err) return } - if err := issues_model.CommentList(comments).LoadAttachments(ctx); err != nil { + if err := comments.LoadAttachments(ctx); err != nil { ctx.Error(http.StatusInternalServerError, "LoadAttachments", err) return } - if _, err := issues_model.CommentList(comments).Issues().LoadRepositories(ctx); err != nil { + if _, err := comments.Issues().LoadRepositories(ctx); err != nil { ctx.Error(http.StatusInternalServerError, "LoadRepositories", err) return } From c59a057297c782f44a81a3e630b5094a58099edb Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Sun, 21 May 2023 23:13:47 +0800 Subject: [PATCH 18/27] Refactor rename user and rename organization (#24052) This PR is a refactor at the beginning. And now it did 4 things. - [x] Move renaming organizaiton and user logics into services layer and merged as one function - [x] Support rename a user capitalization only. For example, rename the user from `Lunny` to `lunny`. We just need to change one table `user` and others should not be touched. - [x] Before this PR, some renaming were missed like `agit` - [x] Fix bug the API reutrned from `http.StatusNoContent` to `http.StatusOK` --- models/repo/repo.go | 8 ++ models/user/error.go | 38 ++++++-- models/user/user.go | 45 ---------- options/locale/locale_en-US.ini | 1 + routers/api/v1/admin/user.go | 14 +-- routers/web/org/setting.go | 39 ++++---- routers/web/user/setting/profile.go | 15 ++-- services/org/org.go | 17 ++-- services/user/avatar.go | 62 +++++++++++++ services/user/rename.go | 41 --------- services/user/user.go | 134 +++++++++++++++++----------- tests/integration/api_admin_test.go | 41 +++++++++ 12 files changed, 267 insertions(+), 188 deletions(-) create mode 100644 services/user/avatar.go delete mode 100644 services/user/rename.go diff --git a/models/repo/repo.go b/models/repo/repo.go index 00e875407cf46..bcf8e5bbe8e04 100644 --- a/models/repo/repo.go +++ b/models/repo/repo.go @@ -832,3 +832,11 @@ func FixNullArchivedRepository(ctx context.Context) (int64, error) { IsArchived: false, }) } + +// UpdateRepositoryOwnerName updates the owner name of all repositories owned by the user +func UpdateRepositoryOwnerName(ctx context.Context, oldUserName, newUserName string) error { + if _, err := db.GetEngine(ctx).Exec("UPDATE `repository` SET owner_name=? WHERE owner_name=?", newUserName, oldUserName); err != nil { + return fmt.Errorf("change repo owner name: %w", err) + } + return nil +} diff --git a/models/user/error.go b/models/user/error.go index 306b9ee9d9b8f..f512994169566 100644 --- a/models/user/error.go +++ b/models/user/error.go @@ -9,13 +9,6 @@ import ( "code.gitea.io/gitea/modules/util" ) -// ____ ___ -// | | \______ ___________ -// | | / ___// __ \_ __ \ -// | | /\___ \\ ___/| | \/ -// |______//____ >\___ >__| -// \/ \/ - // ErrUserAlreadyExist represents a "user already exists" error. type ErrUserAlreadyExist struct { Name string @@ -99,3 +92,34 @@ func (err ErrUserInactive) Error() string { func (err ErrUserInactive) Unwrap() error { return util.ErrPermissionDenied } + +// ErrUserIsNotLocal represents a "ErrUserIsNotLocal" kind of error. +type ErrUserIsNotLocal struct { + UID int64 + Name string +} + +func (err ErrUserIsNotLocal) Error() string { + return fmt.Sprintf("user is not local type [uid: %d, name: %s]", err.UID, err.Name) +} + +// IsErrUserIsNotLocal +func IsErrUserIsNotLocal(err error) bool { + _, ok := err.(ErrUserIsNotLocal) + return ok +} + +type ErrUsernameNotChanged struct { + UID int64 + Name string +} + +func (err ErrUsernameNotChanged) Error() string { + return fmt.Sprintf("username hasn't been changed[uid: %d, name: %s]", err.UID, err.Name) +} + +// IsErrUsernameNotChanged +func IsErrUsernameNotChanged(err error) bool { + _, ok := err.(ErrUsernameNotChanged) + return ok +} diff --git a/models/user/user.go b/models/user/user.go index 46c4440e5f07e..7428e51065d59 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -9,7 +9,6 @@ import ( "encoding/hex" "fmt" "net/url" - "os" "path/filepath" "strings" "time" @@ -756,50 +755,6 @@ func VerifyUserActiveCode(code string) (user *User) { return nil } -// ChangeUserName changes all corresponding setting from old user name to new one. -func ChangeUserName(ctx context.Context, u *User, newUserName string) (err error) { - oldUserName := u.Name - if err = IsUsableUsername(newUserName); err != nil { - return err - } - - ctx, committer, err := db.TxContext(ctx) - if err != nil { - return err - } - defer committer.Close() - - isExist, err := IsUserExist(ctx, 0, newUserName) - if err != nil { - return err - } else if isExist { - return ErrUserAlreadyExist{newUserName} - } - - if _, err = db.GetEngine(ctx).Exec("UPDATE `repository` SET owner_name=? WHERE owner_name=?", newUserName, oldUserName); err != nil { - return fmt.Errorf("Change repo owner name: %w", err) - } - - // Do not fail if directory does not exist - if err = util.Rename(UserPath(oldUserName), UserPath(newUserName)); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("Rename user directory: %w", err) - } - - if err = NewUserRedirect(ctx, u.ID, oldUserName, newUserName); err != nil { - return err - } - - if err = committer.Commit(); err != nil { - if err2 := util.Rename(UserPath(newUserName), UserPath(oldUserName)); err2 != nil && !os.IsNotExist(err2) { - log.Critical("Unable to rollback directory change during failed username change from: %s to: %s. DB Error: %v. Filesystem Error: %v", oldUserName, newUserName, err, err2) - return fmt.Errorf("failed to rollback directory change during failed username change from: %s to: %s. DB Error: %w. Filesystem Error: %v", oldUserName, newUserName, err, err2) - } - return err - } - - return nil -} - // checkDupEmail checks whether there are the same email with the user func checkDupEmail(ctx context.Context, u *User) error { u.Email = strings.ToLower(u.Email) diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 6f85bc4d2d977..e092d3722cf45 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -520,6 +520,7 @@ lang_select_error = Select a language from the list. username_been_taken = The username is already taken. username_change_not_local_user = Non-local users are not allowed to change their username. +username_has_not_been_changed = Username has not been changed repo_name_been_taken = The repository name is already used. repository_force_private = Force Private is enabled: private repositories cannot be made public. repository_files_already_exist = Files already exist for this repository. Contact the system administrator. diff --git a/routers/api/v1/admin/user.go b/routers/api/v1/admin/user.go index 8afa83aa94fe2..c3af5dc90a75e 100644 --- a/routers/api/v1/admin/user.go +++ b/routers/api/v1/admin/user.go @@ -502,17 +502,15 @@ func RenameUser(ctx *context.APIContext) { return } + oldName := ctx.ContextUser.Name newName := web.GetForm(ctx).(*api.RenameUserOption).NewName - if strings.EqualFold(newName, ctx.ContextUser.Name) { - // Noop as username is not changed - ctx.Status(http.StatusNoContent) - return - } - // Check if user name has been changed if err := user_service.RenameUser(ctx, ctx.ContextUser, newName); err != nil { switch { + case user_model.IsErrUsernameNotChanged(err): + // Noop as username is not changed + ctx.Status(http.StatusNoContent) case user_model.IsErrUserAlreadyExist(err): ctx.Error(http.StatusUnprocessableEntity, "", ctx.Tr("form.username_been_taken")) case db.IsErrNameReserved(err): @@ -526,5 +524,7 @@ func RenameUser(ctx *context.APIContext) { } return } - ctx.Status(http.StatusNoContent) + + log.Trace("User name changed: %s -> %s", oldName, newName) + ctx.Status(http.StatusOK) } diff --git a/routers/web/org/setting.go b/routers/web/org/setting.go index a8ad1daece3e9..2c4a6b93e39e0 100644 --- a/routers/web/org/setting.go +++ b/routers/web/org/setting.go @@ -22,8 +22,7 @@ import ( "code.gitea.io/gitea/modules/web" user_setting "code.gitea.io/gitea/routers/web/user/setting" "code.gitea.io/gitea/services/forms" - "code.gitea.io/gitea/services/org" - container_service "code.gitea.io/gitea/services/packages/container" + org_service "code.gitea.io/gitea/services/org" repo_service "code.gitea.io/gitea/services/repository" user_service "code.gitea.io/gitea/services/user" ) @@ -67,31 +66,23 @@ func SettingsPost(ctx *context.Context) { nameChanged := org.Name != form.Name // Check if organization name has been changed. - if org.LowerName != strings.ToLower(form.Name) { - isExist, err := user_model.IsUserExist(ctx, org.ID, form.Name) - if err != nil { - ctx.ServerError("IsUserExist", err) - return - } else if isExist { + if nameChanged { + err := org_service.RenameOrganization(ctx, org, form.Name) + switch { + case user_model.IsErrUserAlreadyExist(err): ctx.Data["OrgName"] = true ctx.RenderWithErr(ctx.Tr("form.username_been_taken"), tplSettingsOptions, &form) return - } else if err = user_model.ChangeUserName(ctx, org.AsUser(), form.Name); err != nil { - switch { - case db.IsErrNameReserved(err): - ctx.Data["OrgName"] = true - ctx.RenderWithErr(ctx.Tr("repo.form.name_reserved", err.(db.ErrNameReserved).Name), tplSettingsOptions, &form) - case db.IsErrNamePatternNotAllowed(err): - ctx.Data["OrgName"] = true - ctx.RenderWithErr(ctx.Tr("repo.form.name_pattern_not_allowed", err.(db.ErrNamePatternNotAllowed).Pattern), tplSettingsOptions, &form) - default: - ctx.ServerError("ChangeUserName", err) - } + case db.IsErrNameReserved(err): + ctx.Data["OrgName"] = true + ctx.RenderWithErr(ctx.Tr("repo.form.name_reserved", err.(db.ErrNameReserved).Name), tplSettingsOptions, &form) return - } - - if err := container_service.UpdateRepositoryNames(ctx, org.AsUser(), form.Name); err != nil { - ctx.ServerError("UpdateRepositoryNames", err) + case db.IsErrNamePatternNotAllowed(err): + ctx.Data["OrgName"] = true + ctx.RenderWithErr(ctx.Tr("repo.form.name_pattern_not_allowed", err.(db.ErrNamePatternNotAllowed).Pattern), tplSettingsOptions, &form) + return + case err != nil: + ctx.ServerError("org_service.RenameOrganization", err) return } @@ -186,7 +177,7 @@ func SettingsDelete(ctx *context.Context) { return } - if err := org.DeleteOrganization(ctx.Org.Organization); err != nil { + if err := org_service.DeleteOrganization(ctx.Org.Organization); err != nil { if models.IsErrUserOwnRepos(err) { ctx.Flash.Error(ctx.Tr("form.org_still_own_repo")) ctx.Redirect(ctx.Org.OrgLink + "/settings/delete") diff --git a/routers/web/user/setting/profile.go b/routers/web/user/setting/profile.go index 0a8a5e6280c46..47066d5e384ef 100644 --- a/routers/web/user/setting/profile.go +++ b/routers/web/user/setting/profile.go @@ -49,15 +49,16 @@ func Profile(ctx *context.Context) { // HandleUsernameChange handle username changes from user settings and admin interface func HandleUsernameChange(ctx *context.Context, user *user_model.User, newName string) error { - // Non-local users are not allowed to change their username. - if !user.IsLocal() { - ctx.Flash.Error(ctx.Tr("form.username_change_not_local_user")) - return fmt.Errorf(ctx.Tr("form.username_change_not_local_user")) - } - + oldName := user.Name // rename user if err := user_service.RenameUser(ctx, user, newName); err != nil { switch { + // Noop as username is not changed + case user_model.IsErrUsernameNotChanged(err): + ctx.Flash.Error(ctx.Tr("form.username_has_not_been_changed")) + // Non-local users are not allowed to change their username. + case user_model.IsErrUserIsNotLocal(err): + ctx.Flash.Error(ctx.Tr("form.username_change_not_local_user")) case user_model.IsErrUserAlreadyExist(err): ctx.Flash.Error(ctx.Tr("form.username_been_taken")) case user_model.IsErrEmailAlreadyUsed(err): @@ -73,7 +74,7 @@ func HandleUsernameChange(ctx *context.Context, user *user_model.User, newName s } return err } - + log.Trace("User name changed: %s -> %s", oldName, newName) return nil } diff --git a/services/org/org.go b/services/org/org.go index e45fb305debe8..a62e5b6fc8f6d 100644 --- a/services/org/org.go +++ b/services/org/org.go @@ -4,20 +4,22 @@ package org import ( + "context" "fmt" "code.gitea.io/gitea/models" "code.gitea.io/gitea/models/db" - "code.gitea.io/gitea/models/organization" + org_model "code.gitea.io/gitea/models/organization" packages_model "code.gitea.io/gitea/models/packages" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/modules/util" + user_service "code.gitea.io/gitea/services/user" ) // DeleteOrganization completely and permanently deletes everything of organization. -func DeleteOrganization(org *organization.Organization) error { +func DeleteOrganization(org *org_model.Organization) error { ctx, commiter, err := db.TxContext(db.DefaultContext) if err != nil { return err @@ -39,7 +41,7 @@ func DeleteOrganization(org *organization.Organization) error { return models.ErrUserOwnPackages{UID: org.ID} } - if err := organization.DeleteOrganization(ctx, org); err != nil { + if err := org_model.DeleteOrganization(ctx, org); err != nil { return fmt.Errorf("DeleteOrganization: %w", err) } @@ -53,15 +55,20 @@ func DeleteOrganization(org *organization.Organization) error { path := user_model.UserPath(org.Name) if err := util.RemoveAll(path); err != nil { - return fmt.Errorf("Failed to RemoveAll %s: %w", path, err) + return fmt.Errorf("failed to RemoveAll %s: %w", path, err) } if len(org.Avatar) > 0 { avatarPath := org.CustomAvatarRelativePath() if err := storage.Avatars.Delete(avatarPath); err != nil { - return fmt.Errorf("Failed to remove %s: %w", avatarPath, err) + return fmt.Errorf("failed to remove %s: %w", avatarPath, err) } } return nil } + +// RenameOrganization renames an organization. +func RenameOrganization(ctx context.Context, org *org_model.Organization, newName string) error { + return user_service.RenameUser(ctx, org.AsUser(), newName) +} diff --git a/services/user/avatar.go b/services/user/avatar.go new file mode 100644 index 0000000000000..26c100abdbede --- /dev/null +++ b/services/user/avatar.go @@ -0,0 +1,62 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "fmt" + "io" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/avatar" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/storage" +) + +// UploadAvatar saves custom avatar for user. +func UploadAvatar(u *user_model.User, data []byte) error { + avatarData, err := avatar.ProcessAvatarImage(data) + if err != nil { + return err + } + + ctx, committer, err := db.TxContext(db.DefaultContext) + if err != nil { + return err + } + defer committer.Close() + + u.UseCustomAvatar = true + u.Avatar = avatar.HashAvatar(u.ID, data) + if err = user_model.UpdateUserCols(ctx, u, "use_custom_avatar", "avatar"); err != nil { + return fmt.Errorf("updateUser: %w", err) + } + + if err := storage.SaveFrom(storage.Avatars, u.CustomAvatarRelativePath(), func(w io.Writer) error { + _, err := w.Write(avatarData) + return err + }); err != nil { + return fmt.Errorf("Failed to create dir %s: %w", u.CustomAvatarRelativePath(), err) + } + + return committer.Commit() +} + +// DeleteAvatar deletes the user's custom avatar. +func DeleteAvatar(u *user_model.User) error { + aPath := u.CustomAvatarRelativePath() + log.Trace("DeleteAvatar[%d]: %s", u.ID, aPath) + if len(u.Avatar) > 0 { + if err := storage.Avatars.Delete(aPath); err != nil { + return fmt.Errorf("Failed to remove %s: %w", aPath, err) + } + } + + u.UseCustomAvatar = false + u.Avatar = "" + if _, err := db.GetEngine(db.DefaultContext).ID(u.ID).Cols("avatar, use_custom_avatar").Update(u); err != nil { + return fmt.Errorf("UpdateUser: %w", err) + } + return nil +} diff --git a/services/user/rename.go b/services/user/rename.go deleted file mode 100644 index af195d7d76a26..0000000000000 --- a/services/user/rename.go +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright 2023 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package user - -import ( - "context" - "fmt" - "strings" - - user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/services/agit" - container_service "code.gitea.io/gitea/services/packages/container" -) - -func renameUser(ctx context.Context, u *user_model.User, newUserName string) error { - if u.IsOrganization() { - return fmt.Errorf("cannot rename organization") - } - - if err := user_model.ChangeUserName(ctx, u, newUserName); err != nil { - return err - } - - if err := agit.UserNameChanged(ctx, u, newUserName); err != nil { - return err - } - if err := container_service.UpdateRepositoryNames(ctx, u, newUserName); err != nil { - return err - } - - u.Name = newUserName - u.LowerName = strings.ToLower(newUserName) - if err := user_model.UpdateUser(ctx, u, false); err != nil { - return err - } - - log.Trace("User name changed: %s -> %s", u.Name, newUserName) - return nil -} diff --git a/services/user/user.go b/services/user/user.go index 5148f2168d585..e0815bd860791 100644 --- a/services/user/user.go +++ b/services/user/user.go @@ -6,7 +6,8 @@ package user import ( "context" "fmt" - "io" + "os" + "strings" "time" "code.gitea.io/gitea/models" @@ -17,29 +18,105 @@ import ( repo_model "code.gitea.io/gitea/models/repo" system_model "code.gitea.io/gitea/models/system" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/avatar" "code.gitea.io/gitea/modules/eventsource" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/agit" "code.gitea.io/gitea/services/packages" + container_service "code.gitea.io/gitea/services/packages/container" ) // RenameUser renames a user func RenameUser(ctx context.Context, u *user_model.User, newUserName string) error { + // Non-local users are not allowed to change their username. + if !u.IsOrganization() && !u.IsLocal() { + return user_model.ErrUserIsNotLocal{ + UID: u.ID, + Name: u.Name, + } + } + + if newUserName == u.Name { + return user_model.ErrUsernameNotChanged{ + UID: u.ID, + Name: u.Name, + } + } + + if err := user_model.IsUsableUsername(newUserName); err != nil { + return err + } + + onlyCapitalization := strings.EqualFold(newUserName, u.Name) + oldUserName := u.Name + + if onlyCapitalization { + u.Name = newUserName + if err := user_model.UpdateUserCols(ctx, u, "name"); err != nil { + u.Name = oldUserName + return err + } + return nil + } + ctx, committer, err := db.TxContext(ctx) if err != nil { return err } defer committer.Close() - if err := renameUser(ctx, u, newUserName); err != nil { + + isExist, err := user_model.IsUserExist(ctx, u.ID, newUserName) + if err != nil { return err } - if err := committer.Commit(); err != nil { + if isExist { + return user_model.ErrUserAlreadyExist{ + Name: newUserName, + } + } + + if err = repo_model.UpdateRepositoryOwnerName(ctx, oldUserName, newUserName); err != nil { + return err + } + + if err = user_model.NewUserRedirect(ctx, u.ID, oldUserName, newUserName); err != nil { + return err + } + + if err := agit.UserNameChanged(ctx, u, newUserName); err != nil { + return err + } + if err := container_service.UpdateRepositoryNames(ctx, u, newUserName); err != nil { + return err + } + + u.Name = newUserName + u.LowerName = strings.ToLower(newUserName) + if err := user_model.UpdateUserCols(ctx, u, "name", "lower_name"); err != nil { + u.Name = oldUserName + u.LowerName = strings.ToLower(oldUserName) return err } - return err + + // Do not fail if directory does not exist + if err = util.Rename(user_model.UserPath(oldUserName), user_model.UserPath(newUserName)); err != nil && !os.IsNotExist(err) { + u.Name = oldUserName + u.LowerName = strings.ToLower(oldUserName) + return fmt.Errorf("rename user directory: %w", err) + } + + if err = committer.Commit(); err != nil { + u.Name = oldUserName + u.LowerName = strings.ToLower(oldUserName) + if err2 := util.Rename(user_model.UserPath(newUserName), user_model.UserPath(oldUserName)); err2 != nil && !os.IsNotExist(err2) { + log.Critical("Unable to rollback directory change during failed username change from: %s to: %s. DB Error: %v. Filesystem Error: %v", oldUserName, newUserName, err, err2) + return fmt.Errorf("failed to rollback directory change during failed username change from: %s to: %s. DB Error: %w. Filesystem Error: %v", oldUserName, newUserName, err, err2) + } + return err + } + return nil } // DeleteUser completely and permanently deletes everything of a user, @@ -240,50 +317,3 @@ func DeleteInactiveUsers(ctx context.Context, olderThan time.Duration) error { return user_model.DeleteInactiveEmailAddresses(ctx) } - -// UploadAvatar saves custom avatar for user. -func UploadAvatar(u *user_model.User, data []byte) error { - avatarData, err := avatar.ProcessAvatarImage(data) - if err != nil { - return err - } - - ctx, committer, err := db.TxContext(db.DefaultContext) - if err != nil { - return err - } - defer committer.Close() - - u.UseCustomAvatar = true - u.Avatar = avatar.HashAvatar(u.ID, data) - if err = user_model.UpdateUserCols(ctx, u, "use_custom_avatar", "avatar"); err != nil { - return fmt.Errorf("updateUser: %w", err) - } - - if err := storage.SaveFrom(storage.Avatars, u.CustomAvatarRelativePath(), func(w io.Writer) error { - _, err := w.Write(avatarData) - return err - }); err != nil { - return fmt.Errorf("Failed to create dir %s: %w", u.CustomAvatarRelativePath(), err) - } - - return committer.Commit() -} - -// DeleteAvatar deletes the user's custom avatar. -func DeleteAvatar(u *user_model.User) error { - aPath := u.CustomAvatarRelativePath() - log.Trace("DeleteAvatar[%d]: %s", u.ID, aPath) - if len(u.Avatar) > 0 { - if err := storage.Avatars.Delete(aPath); err != nil { - return fmt.Errorf("Failed to remove %s: %w", aPath, err) - } - } - - u.UseCustomAvatar = false - u.Avatar = "" - if _, err := db.GetEngine(db.DefaultContext).ID(u.ID).Cols("avatar, use_custom_avatar").Update(u); err != nil { - return fmt.Errorf("UpdateUser: %w", err) - } - return nil -} diff --git a/tests/integration/api_admin_test.go b/tests/integration/api_admin_test.go index 53e4849aa5e16..7cfc3276eebc4 100644 --- a/tests/integration/api_admin_test.go +++ b/tests/integration/api_admin_test.go @@ -241,3 +241,44 @@ func TestAPICreateRepoForUser(t *testing.T) { ) MakeRequest(t, req, http.StatusCreated) } + +func TestAPIRenameUser(t *testing.T) { + defer tests.PrepareTestEnv(t)() + adminUsername := "user1" + token := getUserToken(t, adminUsername, auth_model.AccessTokenScopeSudo) + urlStr := fmt.Sprintf("/api/v1/admin/users/%s/rename?token=%s", "user2", token) + req := NewRequestWithValues(t, "POST", urlStr, map[string]string{ + // required + "new_name": "User2", + }) + MakeRequest(t, req, http.StatusOK) + + urlStr = fmt.Sprintf("/api/v1/admin/users/%s/rename?token=%s", "User2", token) + req = NewRequestWithValues(t, "POST", urlStr, map[string]string{ + // required + "new_name": "User2-2-2", + }) + MakeRequest(t, req, http.StatusOK) + + urlStr = fmt.Sprintf("/api/v1/admin/users/%s/rename?token=%s", "User2", token) + req = NewRequestWithValues(t, "POST", urlStr, map[string]string{ + // required + "new_name": "user1", + }) + // the old user name still be used by with a redirect + MakeRequest(t, req, http.StatusTemporaryRedirect) + + urlStr = fmt.Sprintf("/api/v1/admin/users/%s/rename?token=%s", "User2-2-2", token) + req = NewRequestWithValues(t, "POST", urlStr, map[string]string{ + // required + "new_name": "user1", + }) + MakeRequest(t, req, http.StatusUnprocessableEntity) + + urlStr = fmt.Sprintf("/api/v1/admin/users/%s/rename?token=%s", "User2-2-2", token) + req = NewRequestWithValues(t, "POST", urlStr, map[string]string{ + // required + "new_name": "user2", + }) + MakeRequest(t, req, http.StatusOK) +} From e95b42e187cde9ac4bd541cd714bdb4f5c1fd8bc Mon Sep 17 00:00:00 2001 From: delvh Date: Sun, 21 May 2023 22:47:41 +0200 Subject: [PATCH 19/27] Improve accessibility when (re-)viewing files (#24817) Visually, nothing should have changed. Changes include - Convert most `` to ` +

@@ -52,7 +52,7 @@ {{if $.EscapeStatus.Escaped}} {{if $row.EscapeStatus.Escaped}} - + {{end}} {{end}} diff --git a/templates/repo/diff/blob_excerpt.tmpl b/templates/repo/diff/blob_excerpt.tmpl index 7e11b33ba6e88..ab7abdbd7be2c 100644 --- a/templates/repo/diff/blob_excerpt.tmpl +++ b/templates/repo/diff/blob_excerpt.tmpl @@ -3,28 +3,30 @@ {{if eq .GetType 4}} +
{{if or (eq $line.GetExpandDirection 3) (eq $line.GetExpandDirection 5)}} - + {{end}} {{if or (eq $line.GetExpandDirection 3) (eq $line.GetExpandDirection 4)}} - + {{end}} {{if eq $line.GetExpandDirection 2}} - + {{end}} +
{{$inlineDiff := $.section.GetComputedInlineDiffFor $line $.locale}}{{/* */}}{{template "repo/diff/section_code" dict "diff" $inlineDiff "locale" $.locale}} {{else}} {{$inlineDiff := $.section.GetComputedInlineDiffFor $line $.locale}} - {{if and $line.LeftIdx $inlineDiff.EscapeStatus.Escaped}}{{end}} + {{if and $line.LeftIdx $inlineDiff.EscapeStatus.Escaped}}{{end}} {{if $line.LeftIdx}}{{end}} {{/* */}}{{if $line.LeftIdx}}{{template "repo/diff/section_code" dict "diff" $inlineDiff "locale" $.locale}}{{else}}{{/* @@ -32,7 +34,7 @@ */}}{{end}}{{/* */}} - {{if and $line.RightIdx $inlineDiff.EscapeStatus.Escaped}}{{end}} + {{if and $line.RightIdx $inlineDiff.EscapeStatus.Escaped}}{{end}} {{if $line.RightIdx}}{{end}} {{/* */}}{{if $line.RightIdx}}{{template "repo/diff/section_code" dict "diff" $inlineDiff "locale" $.locale}}{{else}}{{/* @@ -47,28 +49,30 @@ {{if eq .GetType 4}} - {{if or (eq $line.GetExpandDirection 3) (eq $line.GetExpandDirection 5)}} - - {{svg "octicon-fold-down"}} - - {{end}} - {{if or (eq $line.GetExpandDirection 3) (eq $line.GetExpandDirection 4)}} - - {{svg "octicon-fold-up"}} - - {{end}} - {{if eq $line.GetExpandDirection 2}} - - {{svg "octicon-fold"}} - - {{end}} +
+ {{if or (eq $line.GetExpandDirection 3) (eq $line.GetExpandDirection 5)}} + + {{end}} + {{if or (eq $line.GetExpandDirection 3) (eq $line.GetExpandDirection 4)}} + + {{end}} + {{if eq $line.GetExpandDirection 2}} + + {{end}} +
{{else}} {{end}} {{$inlineDiff := $.section.GetComputedInlineDiffFor $line $.locale}} - {{if $inlineDiff.EscapeStatus.Escaped}}{{end}} + {{if $inlineDiff.EscapeStatus.Escaped}}{{end}} {{$inlineDiff.Content}} diff --git a/templates/repo/diff/box.tmpl b/templates/repo/diff/box.tmpl index 26b863aceae5c..740b39dbd5f89 100644 --- a/templates/repo/diff/box.tmpl +++ b/templates/repo/diff/box.tmpl @@ -90,13 +90,13 @@

- +
{{if $file.IsBin}} @@ -125,8 +125,8 @@ {{$.locale.Tr "repo.diff.protected"}} {{end}} {{if not (or $file.IsIncomplete $file.IsBin $file.IsSubmodule)}} - {{$.locale.Tr "repo.unescape_control_characters"}} - {{$.locale.Tr "repo.escape_control_characters"}} + + {{end}} {{if and (not $file.IsSubmodule) (not $.PageIsWiki)}} {{if $file.IsDeleted}} diff --git a/templates/repo/diff/section_split.tmpl b/templates/repo/diff/section_split.tmpl index 42aed8508459c..cb96d64056ea2 100644 --- a/templates/repo/diff/section_split.tmpl +++ b/templates/repo/diff/section_split.tmpl @@ -16,23 +16,25 @@ {{if eq .GetType 4}} +
{{if or (eq $line.GetExpandDirection 3) (eq $line.GetExpandDirection 5)}} - + {{end}} {{if or (eq $line.GetExpandDirection 3) (eq $line.GetExpandDirection 4)}} - + {{end}} {{if eq $line.GetExpandDirection 2}} - + {{end}} +
{{$inlineDiff := $section.GetComputedInlineDiffFor $line $.root.locale}} - {{if $inlineDiff.EscapeStatus.Escaped}}{{end}} + {{if $inlineDiff.EscapeStatus.Escaped}}{{end}} {{/* */}}{{template "repo/diff/section_code" dict "diff" $inlineDiff "locale" $.root.locale}}{{/* */}} @@ -41,7 +43,7 @@ {{- $leftDiff := ""}}{{if $line.LeftIdx}}{{$leftDiff = $section.GetComputedInlineDiffFor $line $.root.locale}}{{end}} {{- $rightDiff := ""}}{{if $match.RightIdx}}{{$rightDiff = $section.GetComputedInlineDiffFor $match $.root.locale}}{{end}} - {{if $line.LeftIdx}}{{if $leftDiff.EscapeStatus.Escaped}}{{end}}{{end}} + {{if $line.LeftIdx}}{{if $leftDiff.EscapeStatus.Escaped}}{{end}}{{end}} {{if $match.RightIdx}}{{end}} {{/* */}}{{if and $.root.SignedUserID $.root.PageIsPullFiles}}{{/* @@ -73,7 +75,7 @@ {{else}} {{$inlineDiff := $section.GetComputedInlineDiffFor $line $.root.locale}} - {{if $line.LeftIdx}}{{if $inlineDiff.EscapeStatus.Escaped}}{{end}}{{end}} + {{if $line.LeftIdx}}{{if $inlineDiff.EscapeStatus.Escaped}}{{end}}{{end}} {{if $line.LeftIdx}}{{end}} {{/* */}}{{if and $.root.SignedUserID $.root.PageIsPullFiles (not (eq .GetType 2))}}{{/* @@ -88,7 +90,7 @@ */}}{{end}}{{/* */}} - {{if $line.RightIdx}}{{if $inlineDiff.EscapeStatus.Escaped}}{{end}}{{end}} + {{if $line.RightIdx}}{{if $inlineDiff.EscapeStatus.Escaped}}{{end}}{{end}} {{if $line.RightIdx}}{{end}} {{/* */}}{{if and $.root.SignedUserID $.root.PageIsPullFiles (not (eq .GetType 3))}}{{/* diff --git a/templates/repo/diff/section_unified.tmpl b/templates/repo/diff/section_unified.tmpl index 8bf331f6db956..d9708531592f0 100644 --- a/templates/repo/diff/section_unified.tmpl +++ b/templates/repo/diff/section_unified.tmpl @@ -12,21 +12,23 @@ {{if eq .GetType 4}} {{if $.root.AfterCommitID}} - {{if or (eq $line.GetExpandDirection 3) (eq $line.GetExpandDirection 5)}} - - {{svg "octicon-fold-down"}} - - {{end}} - {{if or (eq $line.GetExpandDirection 3) (eq $line.GetExpandDirection 4)}} - - {{svg "octicon-fold-up"}} - - {{end}} - {{if eq $line.GetExpandDirection 2}} - - {{svg "octicon-fold"}} - - {{end}} +
+ {{if or (eq $line.GetExpandDirection 3) (eq $line.GetExpandDirection 5)}} + + {{end}} + {{if or (eq $line.GetExpandDirection 3) (eq $line.GetExpandDirection 4)}} + + {{end}} + {{if eq $line.GetExpandDirection 2}} + + {{end}} +
{{else}} {{/* for code file preview page or comment diffs on pull comment pages, do not show the expansion arrows */}} @@ -37,7 +39,11 @@ {{end}} {{$inlineDiff := $section.GetComputedInlineDiffFor $line $.root.locale -}} - {{if $inlineDiff.EscapeStatus.Escaped}}{{end}} + + {{- if $inlineDiff.EscapeStatus.Escaped -}} + + {{- end -}} + {{if eq .GetType 4}} {{/* diff --git a/templates/repo/issue/labels/labels_selector_field.tmpl b/templates/repo/issue/labels/labels_selector_field.tmpl index 00d852bba98b3..350cf631e83a0 100644 --- a/templates/repo/issue/labels/labels_selector_field.tmpl +++ b/templates/repo/issue/labels/labels_selector_field.tmpl @@ -1,10 +1,10 @@

@@ -107,7 +107,7 @@ {{if $.EscapeStatus.Escaped}} - {{if (index $.LineEscapeStatus $idx).Escaped}}{{end}} + {{if (index $.LineEscapeStatus $idx).Escaped}}{{end}} {{end}} {{$code | Safe}} diff --git a/templates/user/settings/profile.tmpl b/templates/user/settings/profile.tmpl index 92fbf5814cbac..dc1bbc84d5761 100644 --- a/templates/user/settings/profile.tmpl +++ b/templates/user/settings/profile.tmpl @@ -125,7 +125,7 @@
- {{$.locale.Tr "settings.delete_current_avatar"}} +
diff --git a/web_src/css/repo.css b/web_src/css/repo.css index 73096a6434650..ddcc258be971a 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -2943,7 +2943,7 @@ tbody.commit-list { padding-top: 0 !important; } -td.blob-excerpt { +.blob-excerpt { background-color: var(--color-secondary-alpha-30); } diff --git a/web_src/css/review.css b/web_src/css/review.css index d57fcc85df237..d44e98584e004 100644 --- a/web_src/css/review.css +++ b/web_src/css/review.css @@ -21,7 +21,7 @@ transform: scale(1.1); } -.lines-escape a.toggle-escape-button::before { +.lines-escape .toggle-escape-button::before { visibility: visible; content: "⚠️"; font-family: var(--fonts-emoji); @@ -198,7 +198,8 @@ color: var(--color-text); } -a.blob-excerpt { +.code-expander-button { + border: none; color: var(--color-text-light); height: 28px; display: flex; @@ -206,9 +207,10 @@ a.blob-excerpt { align-items: center; width: 100%; background: var(--color-expand-button); + flex: 1; } -a.blob-excerpt:hover { +.code-expander-button:hover { background: var(--color-primary); color: var(--color-primary-contrast); } diff --git a/web_src/js/features/common-global.js b/web_src/js/features/common-global.js index 8245aa57f8526..d4d955601e6ab 100644 --- a/web_src/js/features/common-global.js +++ b/web_src/js/features/common-global.js @@ -56,9 +56,8 @@ export function initGlobalEnterQuickSubmit() { } export function initGlobalButtonClickOnEnter() { - $(document).on('keypress', '.ui.button', (e) => { - if (e.keyCode === 13 || e.keyCode === 32) { // enter key or space bar - if (e.target.nodeName === 'BUTTON') return; // button already handles space&enter correctly + $(document).on('keypress', 'div.ui.button,span.ui.button', (e) => { + if (e.code === ' ' || e.code === 'Enter') { $(e.target).trigger('click'); e.preventDefault(); } diff --git a/web_src/js/features/pull-view-file.js b/web_src/js/features/pull-view-file.js index e57663736a58f..daa520ea7e7f6 100644 --- a/web_src/js/features/pull-view-file.js +++ b/web_src/js/features/pull-view-file.js @@ -38,7 +38,7 @@ export function initViewedCheckboxListenerFor() { // The checkbox consists of a div containing the real checkbox with its label and the CSRF token, // hence the actual checkbox first has to be found const checkbox = form.querySelector('input[type=checkbox]'); - checkbox.addEventListener('change', function() { + checkbox.addEventListener('input', function() { // Mark the file as viewed visually - will especially change the background if (this.checked) { form.classList.add(viewedStyleClass); diff --git a/web_src/js/features/repo-code.js b/web_src/js/features/repo-code.js index 9b504068f6979..73eccaf193e39 100644 --- a/web_src/js/features/repo-code.js +++ b/web_src/js/features/repo-code.js @@ -181,7 +181,7 @@ export function initRepoCodeView() { $(document).on('click', '.fold-file', ({currentTarget}) => { invertFileFolding(currentTarget.closest('.file-content'), currentTarget); }); - $(document).on('click', '.blob-excerpt', async ({currentTarget}) => { + $(document).on('click', '.code-expander-button', async ({currentTarget}) => { const url = currentTarget.getAttribute('data-url'); const query = currentTarget.getAttribute('data-query'); const anchor = currentTarget.getAttribute('data-anchor'); diff --git a/web_src/js/features/repo-diff.js b/web_src/js/features/repo-diff.js index 5a9f5ba0a2da8..d0622254bf47d 100644 --- a/web_src/js/features/repo-diff.js +++ b/web_src/js/features/repo-diff.js @@ -62,9 +62,9 @@ function initRepoDiffConversationForm() { $form.closest('.conversation-holder').replaceWith($newConversationHolder); if ($form.closest('tr').data('line-type') === 'same') { - $(`[data-path="${path}"] a.add-code-comment[data-idx="${idx}"]`).addClass('invisible'); + $(`[data-path="${path}"] .add-code-comment[data-idx="${idx}"]`).addClass('invisible'); } else { - $(`[data-path="${path}"] a.add-code-comment[data-side="${side}"][data-idx="${idx}"]`).addClass('invisible'); + $(`[data-path="${path}"] .add-code-comment[data-side="${side}"][data-idx="${idx}"]`).addClass('invisible'); } $newConversationHolder.find('.dropdown').dropdown(); initCompReactionSelector($newConversationHolder); diff --git a/web_src/js/features/repo-issue.js b/web_src/js/features/repo-issue.js index 3723e0f627e63..cf6e09472eeb8 100644 --- a/web_src/js/features/repo-issue.js +++ b/web_src/js/features/repo-issue.js @@ -177,9 +177,9 @@ export function initRepoIssueCommentDelete() { const idx = $conversationHolder.data('idx'); const lineType = $conversationHolder.closest('tr').data('line-type'); if (lineType === 'same') { - $(`[data-path="${path}"] a.add-code-comment[data-idx="${idx}"]`).removeClass('invisible'); + $(`[data-path="${path}"] .add-code-comment[data-idx="${idx}"]`).removeClass('invisible'); } else { - $(`[data-path="${path}"] a.add-code-comment[data-side="${side}"][data-idx="${idx}"]`).removeClass('invisible'); + $(`[data-path="${path}"] .add-code-comment[data-side="${side}"][data-idx="${idx}"]`).removeClass('invisible'); } $conversationHolder.remove(); } @@ -488,7 +488,7 @@ export function initRepoPullRequestReview() { }); } - $(document).on('click', 'a.add-code-comment', async function (e) { + $(document).on('click', '.add-code-comment', async function (e) { if ($(e.target).hasClass('btn-add-single')) return; // https://github.com/go-gitea/gitea/issues/4745 e.preventDefault(); diff --git a/web_src/js/features/repo-unicode-escape.js b/web_src/js/features/repo-unicode-escape.js index 67f2df1d9a673..6a201ec4d1323 100644 --- a/web_src/js/features/repo-unicode-escape.js +++ b/web_src/js/features/repo-unicode-escape.js @@ -2,30 +2,30 @@ import $ from 'jquery'; import {hideElem, showElem} from '../utils/dom.js'; export function initUnicodeEscapeButton() { - $(document).on('click', 'a.escape-button', (e) => { + $(document).on('click', '.escape-button', (e) => { e.preventDefault(); $(e.target).parents('.file-content, .non-diff-file-content').find('.file-code, .file-view').addClass('unicode-escaped'); hideElem($(e.target)); - showElem($(e.target).siblings('a.unescape-button')); + showElem($(e.target).siblings('.unescape-button')); }); - $(document).on('click', 'a.unescape-button', (e) => { + $(document).on('click', '.unescape-button', (e) => { e.preventDefault(); $(e.target).parents('.file-content, .non-diff-file-content').find('.file-code, .file-view').removeClass('unicode-escaped'); hideElem($(e.target)); - showElem($(e.target).siblings('a.escape-button')); + showElem($(e.target).siblings('.escape-button')); }); - $(document).on('click', 'a.toggle-escape-button', (e) => { + $(document).on('click', '.toggle-escape-button', (e) => { e.preventDefault(); const fileContent = $(e.target).parents('.file-content, .non-diff-file-content'); const fileView = fileContent.find('.file-code, .file-view'); if (fileView.hasClass('unicode-escaped')) { fileView.removeClass('unicode-escaped'); - hideElem(fileContent.find('a.unescape-button')); - showElem(fileContent.find('a.escape-button')); + hideElem(fileContent.find('.unescape-button')); + showElem(fileContent.find('.escape-button')); } else { fileView.addClass('unicode-escaped'); - showElem(fileContent.find('a.unescape-button')); - hideElem(fileContent.find('a.escape-button')); + showElem(fileContent.find('.unescape-button')); + hideElem(fileContent.find('.escape-button')); } }); } From 268d121f4bf9bd7c0b601937c9232a3e2b233cb6 Mon Sep 17 00:00:00 2001 From: Brecht Van Lommel Date: Sun, 21 May 2023 23:19:37 +0200 Subject: [PATCH 20/27] Fix video width overflow in markdown, and other changes to match img (#24834) This change makes the CSS for `