From 8a9fd7f771f4f694594a0652e95ddda2b7479b3e Mon Sep 17 00:00:00 2001 From: ConcurrentCrab <102517200+ConcurrentCrab@users.noreply.github.com> Date: Fri, 27 Sep 2024 19:57:37 +0530 Subject: [PATCH 1/3] Add pure SSH LFS support (#31516) Fixes #17554 /claim #17554 Docs PR https://gitea.com/gitea/docs/pulls/49 To test, run pushes like: `GIT_TRACE=1` git push. The trace output should mention "pure SSH connection". --- assets/go-licenses.json | 10 + cmd/serv.go | 129 +++++++---- custom/conf/app.example.ini | 2 + go.mod | 4 + go.sum | 4 + modules/lfstransfer/backend/backend.go | 301 +++++++++++++++++++++++++ modules/lfstransfer/backend/lock.go | 296 ++++++++++++++++++++++++ modules/lfstransfer/backend/util.go | 141 ++++++++++++ modules/lfstransfer/logger.go | 21 ++ modules/lfstransfer/main.go | 42 ++++ modules/setting/lfs.go | 1 + routers/private/internal.go | 30 +++ routers/private/serv.go | 17 +- 13 files changed, 945 insertions(+), 53 deletions(-) create mode 100644 modules/lfstransfer/backend/backend.go create mode 100644 modules/lfstransfer/backend/lock.go create mode 100644 modules/lfstransfer/backend/util.go create mode 100644 modules/lfstransfer/logger.go create mode 100644 modules/lfstransfer/main.go diff --git a/assets/go-licenses.json b/assets/go-licenses.json index 0181fd68ae703..62e63f271a838 100644 --- a/assets/go-licenses.json +++ b/assets/go-licenses.json @@ -344,6 +344,11 @@ "path": "github.com/cespare/xxhash/v2/LICENSE.txt", "licenseText": "Copyright (c) 2016 Caleb Spare\n\nMIT License\n\nPermission is hereby granted, free of charge, to any person obtaining\na copy of this software and associated documentation files (the\n\"Software\"), to deal in the Software without restriction, including\nwithout limitation the rights to use, copy, modify, merge, publish,\ndistribute, sublicense, and/or sell copies of the Software, and to\npermit persons to whom the Software is furnished to do so, subject to\nthe following conditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\nNONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE\nLIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION\nWITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n" }, + { + "name": "github.com/charmbracelet/git-lfs-transfer/transfer", + "path": "github.com/charmbracelet/git-lfs-transfer/transfer/LICENSE", + "licenseText": "MIT License\n\nCopyright (c) 2022-2023 Charmbracelet, Inc\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" + }, { "name": "github.com/chi-middleware/proxy", "path": "github.com/chi-middleware/proxy/LICENSE", @@ -464,6 +469,11 @@ "path": "github.com/fxamacker/cbor/v2/LICENSE", "licenseText": "MIT License\n\nCopyright (c) 2019-present Faye Amacker\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE." }, + { + "name": "github.com/git-lfs/pktline", + "path": "github.com/git-lfs/pktline/LICENSE.md", + "licenseText": "MIT License\n\nCopyright (c) 2014- GitHub, Inc. and Git LFS contributors\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\nNote that Git LFS uses components from other Go modules (included in `vendor/`)\nwhich are under different licenses. See those LICENSE files for details.\n" + }, { "name": "github.com/gliderlabs/ssh", "path": "github.com/gliderlabs/ssh/LICENSE", diff --git a/cmd/serv.go b/cmd/serv.go index f74a8fd3d071c..2d2df8aa23b88 100644 --- a/cmd/serv.go +++ b/cmd/serv.go @@ -20,8 +20,10 @@ import ( asymkey_model "code.gitea.io/gitea/models/asymkey" git_model "code.gitea.io/gitea/models/git" "code.gitea.io/gitea/models/perm" + "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/lfstransfer" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/pprof" "code.gitea.io/gitea/modules/private" @@ -36,7 +38,11 @@ import ( ) const ( - lfsAuthenticateVerb = "git-lfs-authenticate" + verbUploadPack = "git-upload-pack" + verbUploadArchive = "git-upload-archive" + verbReceivePack = "git-receive-pack" + verbLfsAuthenticate = "git-lfs-authenticate" + verbLfsTransfer = "git-lfs-transfer" ) // CmdServ represents the available serv sub-command. @@ -73,12 +79,18 @@ func setup(ctx context.Context, debug bool) { } var ( - allowedCommands = map[string]perm.AccessMode{ - "git-upload-pack": perm.AccessModeRead, - "git-upload-archive": perm.AccessModeRead, - "git-receive-pack": perm.AccessModeWrite, - lfsAuthenticateVerb: perm.AccessModeNone, - } + // keep getAccessMode() in sync + allowedCommands = container.SetOf( + verbUploadPack, + verbUploadArchive, + verbReceivePack, + verbLfsAuthenticate, + verbLfsTransfer, + ) + allowedCommandsLfs = container.SetOf( + verbLfsAuthenticate, + verbLfsTransfer, + ) alphaDashDotPattern = regexp.MustCompile(`[^\w-\.]`) ) @@ -124,6 +136,45 @@ func handleCliResponseExtra(extra private.ResponseExtra) error { return nil } +func getAccessMode(verb, lfsVerb string) perm.AccessMode { + switch verb { + case verbUploadPack, verbUploadArchive: + return perm.AccessModeRead + case verbReceivePack: + return perm.AccessModeWrite + case verbLfsAuthenticate, verbLfsTransfer: + switch lfsVerb { + case "upload": + return perm.AccessModeWrite + case "download": + return perm.AccessModeRead + } + } + // should be unreachable + return perm.AccessModeNone +} + +func getLFSAuthToken(ctx context.Context, lfsVerb string, results *private.ServCommandResults) (string, error) { + now := time.Now() + claims := lfs.Claims{ + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(setting.LFS.HTTPAuthExpiry)), + NotBefore: jwt.NewNumericDate(now), + }, + RepoID: results.RepoID, + Op: lfsVerb, + UserID: results.UserID, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + // Sign and get the complete encoded token as a string using the secret + tokenString, err := token.SignedString(setting.LFS.JWTSecretBytes) + if err != nil { + return "", fail(ctx, "Failed to sign JWT Token", "Failed to sign JWT token: %v", err) + } + return fmt.Sprintf("Bearer %s", tokenString), nil +} + func runServ(c *cli.Context) error { ctx, cancel := installSignals() defer cancel() @@ -198,15 +249,6 @@ func runServ(c *cli.Context) error { repoPath := strings.TrimPrefix(words[1], "/") var lfsVerb string - if verb == lfsAuthenticateVerb { - if !setting.LFS.StartServer { - return fail(ctx, "Unknown git command", "LFS authentication request over SSH denied, LFS support is disabled") - } - - if len(words) > 2 { - lfsVerb = words[2] - } - } rr := strings.SplitN(repoPath, "/", 2) if len(rr) != 2 { @@ -243,53 +285,52 @@ func runServ(c *cli.Context) error { }() } - requestedMode, has := allowedCommands[verb] - if !has { + if allowedCommands.Contains(verb) { + if allowedCommandsLfs.Contains(verb) { + if !setting.LFS.StartServer { + return fail(ctx, "Unknown git command", "LFS authentication request over SSH denied, LFS support is disabled") + } + if verb == verbLfsTransfer && !setting.LFS.AllowPureSSH { + return fail(ctx, "Unknown git command", "LFS SSH transfer connection denied, pure SSH protocol is disabled") + } + if len(words) > 2 { + lfsVerb = words[2] + } + } + } else { return fail(ctx, "Unknown git command", "Unknown git command %s", verb) } - if verb == lfsAuthenticateVerb { - if lfsVerb == "upload" { - requestedMode = perm.AccessModeWrite - } else if lfsVerb == "download" { - requestedMode = perm.AccessModeRead - } else { - return fail(ctx, "Unknown LFS verb", "Unknown lfs verb %s", lfsVerb) - } - } + requestedMode := getAccessMode(verb, lfsVerb) results, extra := private.ServCommand(ctx, keyID, username, reponame, requestedMode, verb, lfsVerb) if extra.HasError() { return fail(ctx, extra.UserMsg, "ServCommand failed: %s", extra.Error) } + // LFS SSH protocol + if verb == verbLfsTransfer { + token, err := getLFSAuthToken(ctx, lfsVerb, results) + if err != nil { + return err + } + return lfstransfer.Main(ctx, repoPath, lfsVerb, token) + } + // LFS token authentication - if verb == lfsAuthenticateVerb { + if verb == verbLfsAuthenticate { url := fmt.Sprintf("%s%s/%s.git/info/lfs", setting.AppURL, url.PathEscape(results.OwnerName), url.PathEscape(results.RepoName)) - now := time.Now() - claims := lfs.Claims{ - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(now.Add(setting.LFS.HTTPAuthExpiry)), - NotBefore: jwt.NewNumericDate(now), - }, - RepoID: results.RepoID, - Op: lfsVerb, - UserID: results.UserID, - } - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - - // Sign and get the complete encoded token as a string using the secret - tokenString, err := token.SignedString(setting.LFS.JWTSecretBytes) + token, err := getLFSAuthToken(ctx, lfsVerb, results) if err != nil { - return fail(ctx, "Failed to sign JWT Token", "Failed to sign JWT token: %v", err) + return err } tokenAuthentication := &git_model.LFSTokenResponse{ Header: make(map[string]string), Href: url, } - tokenAuthentication.Header["Authorization"] = fmt.Sprintf("Bearer %s", tokenString) + tokenAuthentication.Header["Authorization"] = token enc := json.NewEncoder(os.Stdout) err = enc.Encode(tokenAuthentication) diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index ad5d3e1abae90..69d541ff8d407 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -306,6 +306,8 @@ RUN_USER = ; git ;; Enables git-lfs support. true or false, default is false. ;LFS_START_SERVER = false ;; +;; Enables git-lfs SSH protocol support. true or false, default is false. +;LFS_ALLOW_PURE_SSH = false ;; ;; LFS authentication secret, change this yourself ;LFS_JWT_SECRET = diff --git a/go.mod b/go.mod index dd36f63986025..3b89e5cee8b74 100644 --- a/go.mod +++ b/go.mod @@ -35,6 +35,7 @@ require ( github.com/blevesearch/bleve/v2 v2.4.2 github.com/buildkite/terminal-to-html/v3 v3.12.1 github.com/caddyserver/certmagic v0.21.3 + github.com/charmbracelet/git-lfs-transfer v0.2.0 github.com/chi-middleware/proxy v1.1.1 github.com/dimiro1/reply v0.0.0-20200315094148-d0136a4c9e21 github.com/djherbis/buffer v1.2.0 @@ -197,6 +198,7 @@ require ( github.com/fatih/color v1.17.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fxamacker/cbor/v2 v2.6.0 // indirect + github.com/git-lfs/pktline v0.0.0-20230103162542-ca444d533ef1 // indirect github.com/go-ap/errors v0.0.0-20240304112515-6077fa9c17b0 // indirect github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect github.com/go-enry/go-oniguruma v1.2.1 // indirect @@ -329,6 +331,8 @@ replace github.com/shurcooL/vfsgen => github.com/lunny/vfsgen v0.0.0-20220105142 replace github.com/nektos/act => gitea.com/gitea/act v0.259.1 +replace github.com/charmbracelet/git-lfs-transfer => gitea.com/gitea/git-lfs-transfer v0.2.0 + // TODO: This could be removed after https://github.com/mholt/archiver/pull/396 merged replace github.com/mholt/archiver/v3 => github.com/anchore/archiver/v3 v3.5.2 diff --git a/go.sum b/go.sum index 690e1301b761d..9a089e0f74e19 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,8 @@ git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078 h1:cliQ4H git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078/go.mod h1:g/V2Hjas6Z1UHUp4yIx6bATpNzJ7DYtD0FG3+xARWxs= gitea.com/gitea/act v0.259.1 h1:8GG1o/xtUHl3qjn5f0h/2FXrT5ubBn05TJOM5ry+FBw= gitea.com/gitea/act v0.259.1/go.mod h1:UxZWRYqQG2Yj4+4OqfGWW5a3HELwejyWFQyU7F1jUD8= +gitea.com/gitea/git-lfs-transfer v0.2.0 h1:baHaNoBSRaeq/xKayEXwiDQtlIjps4Ac/Ll4KqLMB40= +gitea.com/gitea/git-lfs-transfer v0.2.0/go.mod h1:UrXUCm3xLQkq15fu7qlXHUMlrhdlXHoi13KH2Dfiits= gitea.com/go-chi/binding v0.0.0-20240430071103-39a851e106ed h1:EZZBtilMLSZNWtHHcgq2mt6NSGhJSZBuduAlinMEmso= gitea.com/go-chi/binding v0.0.0-20240430071103-39a851e106ed/go.mod h1:E3i3cgB04dDx0v3CytCgRTTn9Z/9x891aet3r456RVw= gitea.com/go-chi/cache v0.2.1 h1:bfAPkvXlbcZxPCpcmDVCWoHgiBSBmZN/QosnZvEC0+g= @@ -291,6 +293,8 @@ github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nos github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/fxamacker/cbor/v2 v2.6.0 h1:sU6J2usfADwWlYDAFhZBQ6TnLFBHxgesMrQfQgk1tWA= github.com/fxamacker/cbor/v2 v2.6.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/git-lfs/pktline v0.0.0-20230103162542-ca444d533ef1 h1:mtDjlmloH7ytdblogrMz1/8Hqua1y8B4ID+bh3rvod0= +github.com/git-lfs/pktline v0.0.0-20230103162542-ca444d533ef1/go.mod h1:fenKRzpXDjNpsIBhuhUzvjCKlDjKam0boRAenTE0Q6A= github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE= github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8= github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE= diff --git a/modules/lfstransfer/backend/backend.go b/modules/lfstransfer/backend/backend.go new file mode 100644 index 0000000000000..d4523e1abfab5 --- /dev/null +++ b/modules/lfstransfer/backend/backend.go @@ -0,0 +1,301 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package backend + +import ( + "bytes" + "context" + "encoding/base64" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/lfs" + "code.gitea.io/gitea/modules/setting" + + "github.com/charmbracelet/git-lfs-transfer/transfer" +) + +// Version is the git-lfs-transfer protocol version number. +const Version = "1" + +// Capabilities is a list of Git LFS capabilities supported by this package. +var Capabilities = []string{ + "version=" + Version, + "locking", +} + +var _ transfer.Backend = &GiteaBackend{} + +// GiteaBackend is an adapter between git-lfs-transfer library and Gitea's internal LFS API +type GiteaBackend struct { + ctx context.Context + server *url.URL + op string + token string + itoken string + logger transfer.Logger +} + +func New(ctx context.Context, repo, op, token string, logger transfer.Logger) (transfer.Backend, error) { + // runServ guarantees repo will be in form [owner]/[name].git + server, err := url.Parse(setting.LocalURL) + if err != nil { + return nil, err + } + server = server.JoinPath("api/internal/repo", repo, "info/lfs") + return &GiteaBackend{ctx: ctx, server: server, op: op, token: token, itoken: fmt.Sprintf("Bearer %s", setting.InternalToken), logger: logger}, nil +} + +// Batch implements transfer.Backend +func (g *GiteaBackend) Batch(_ string, pointers []transfer.BatchItem, args transfer.Args) ([]transfer.BatchItem, error) { + reqBody := lfs.BatchRequest{Operation: g.op} + if transfer, ok := args[argTransfer]; ok { + reqBody.Transfers = []string{transfer} + } + if ref, ok := args[argRefname]; ok { + reqBody.Ref = &lfs.Reference{Name: ref} + } + reqBody.Objects = make([]lfs.Pointer, len(pointers)) + for i := range pointers { + reqBody.Objects[i].Oid = pointers[i].Oid + reqBody.Objects[i].Size = pointers[i].Size + } + + bodyBytes, err := json.Marshal(reqBody) + if err != nil { + g.logger.Log("json marshal error", err) + return nil, err + } + url := g.server.JoinPath("objects/batch").String() + headers := map[string]string{ + headerAuthorisation: g.itoken, + headerAuthX: g.token, + headerAccept: mimeGitLFS, + headerContentType: mimeGitLFS, + } + req := newInternalRequest(g.ctx, url, http.MethodPost, headers, bodyBytes) + resp, err := req.Response() + if err != nil { + g.logger.Log("http request error", err) + return nil, err + } + if resp.StatusCode != http.StatusOK { + g.logger.Log("http statuscode error", resp.StatusCode, statusCodeToErr(resp.StatusCode)) + return nil, statusCodeToErr(resp.StatusCode) + } + defer resp.Body.Close() + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + g.logger.Log("http read error", err) + return nil, err + } + var respBody lfs.BatchResponse + err = json.Unmarshal(respBytes, &respBody) + if err != nil { + g.logger.Log("json umarshal error", err) + return nil, err + } + + // rebuild slice, we can't rely on order in resp being the same as req + pointers = pointers[:0] + opNum := opMap[g.op] + for _, obj := range respBody.Objects { + pointer := transfer.Pointer{Oid: obj.Pointer.Oid, Size: obj.Pointer.Size} + item := transfer.BatchItem{Pointer: pointer, Args: map[string]string{}} + switch opNum { + case opDownload: + if action, ok := obj.Actions[actionDownload]; ok { + item.Present = true + idMap := obj.Actions + idMapBytes, err := json.Marshal(idMap) + if err != nil { + g.logger.Log("json marshal error", err) + return nil, err + } + idMapStr := base64.StdEncoding.EncodeToString(idMapBytes) + item.Args[argID] = idMapStr + if authHeader, ok := action.Header[headerAuthorisation]; ok { + authHeaderB64 := base64.StdEncoding.EncodeToString([]byte(authHeader)) + item.Args[argToken] = authHeaderB64 + } + if action.ExpiresAt != nil { + item.Args[argExpiresAt] = action.ExpiresAt.String() + } + } else { + // must be an error, but the SSH protocol can't propagate individual errors + g.logger.Log("object not found", obj.Pointer.Oid, obj.Pointer.Size) + item.Present = false + } + case opUpload: + if action, ok := obj.Actions[actionUpload]; ok { + item.Present = false + idMap := obj.Actions + idMapBytes, err := json.Marshal(idMap) + if err != nil { + g.logger.Log("json marshal error", err) + return nil, err + } + idMapStr := base64.StdEncoding.EncodeToString(idMapBytes) + item.Args[argID] = idMapStr + if authHeader, ok := action.Header[headerAuthorisation]; ok { + authHeaderB64 := base64.StdEncoding.EncodeToString([]byte(authHeader)) + item.Args[argToken] = authHeaderB64 + } + if action.ExpiresAt != nil { + item.Args[argExpiresAt] = action.ExpiresAt.String() + } + } else { + item.Present = true + } + } + pointers = append(pointers, item) + } + return pointers, nil +} + +// Download implements transfer.Backend. The returned reader must be closed by the +// caller. +func (g *GiteaBackend) Download(oid string, args transfer.Args) (io.ReadCloser, int64, error) { + idMapStr, exists := args[argID] + if !exists { + return nil, 0, ErrMissingID + } + idMapBytes, err := base64.StdEncoding.DecodeString(idMapStr) + if err != nil { + g.logger.Log("base64 decode error", err) + return nil, 0, transfer.ErrCorruptData + } + idMap := map[string]*lfs.Link{} + err = json.Unmarshal(idMapBytes, &idMap) + if err != nil { + g.logger.Log("json unmarshal error", err) + return nil, 0, transfer.ErrCorruptData + } + action, exists := idMap[actionDownload] + if !exists { + g.logger.Log("argument id incorrect") + return nil, 0, transfer.ErrCorruptData + } + url := action.Href + headers := map[string]string{ + headerAuthorisation: g.itoken, + headerAuthX: g.token, + headerAccept: mimeOctetStream, + } + req := newInternalRequest(g.ctx, url, http.MethodGet, headers, nil) + resp, err := req.Response() + if err != nil { + return nil, 0, err + } + if resp.StatusCode != http.StatusOK { + return nil, 0, statusCodeToErr(resp.StatusCode) + } + defer resp.Body.Close() + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, 0, err + } + respSize := int64(len(respBytes)) + respBuf := io.NopCloser(bytes.NewBuffer(respBytes)) + return respBuf, respSize, nil +} + +// StartUpload implements transfer.Backend. +func (g *GiteaBackend) Upload(oid string, size int64, r io.Reader, args transfer.Args) error { + idMapStr, exists := args[argID] + if !exists { + return ErrMissingID + } + idMapBytes, err := base64.StdEncoding.DecodeString(idMapStr) + if err != nil { + g.logger.Log("base64 decode error", err) + return transfer.ErrCorruptData + } + idMap := map[string]*lfs.Link{} + err = json.Unmarshal(idMapBytes, &idMap) + if err != nil { + g.logger.Log("json unmarshal error", err) + return transfer.ErrCorruptData + } + action, exists := idMap[actionUpload] + if !exists { + g.logger.Log("argument id incorrect") + return transfer.ErrCorruptData + } + url := action.Href + headers := map[string]string{ + headerAuthorisation: g.itoken, + headerAuthX: g.token, + headerContentType: mimeOctetStream, + headerContentLength: strconv.FormatInt(size, 10), + } + reqBytes, err := io.ReadAll(r) + if err != nil { + return err + } + req := newInternalRequest(g.ctx, url, http.MethodPut, headers, reqBytes) + resp, err := req.Response() + if err != nil { + return err + } + if resp.StatusCode != http.StatusOK { + return statusCodeToErr(resp.StatusCode) + } + return nil +} + +// Verify implements transfer.Backend. +func (g *GiteaBackend) Verify(oid string, size int64, args transfer.Args) (transfer.Status, error) { + reqBody := lfs.Pointer{Oid: oid, Size: size} + + bodyBytes, err := json.Marshal(reqBody) + if err != nil { + return transfer.NewStatus(transfer.StatusInternalServerError), err + } + idMapStr, exists := args[argID] + if !exists { + return transfer.NewStatus(transfer.StatusBadRequest, "missing argument: id"), ErrMissingID + } + idMapBytes, err := base64.StdEncoding.DecodeString(idMapStr) + if err != nil { + g.logger.Log("base64 decode error", err) + return transfer.NewStatus(transfer.StatusBadRequest, "corrupt argument: id"), transfer.ErrCorruptData + } + idMap := map[string]*lfs.Link{} + err = json.Unmarshal(idMapBytes, &idMap) + if err != nil { + g.logger.Log("json unmarshal error", err) + return transfer.NewStatus(transfer.StatusBadRequest, "corrupt argument: id"), transfer.ErrCorruptData + } + action, exists := idMap[actionVerify] + if !exists { + // the server sent no verify action + return transfer.SuccessStatus(), nil + } + url := action.Href + headers := map[string]string{ + headerAuthorisation: g.itoken, + headerAuthX: g.token, + headerAccept: mimeGitLFS, + headerContentType: mimeGitLFS, + } + req := newInternalRequest(g.ctx, url, http.MethodPost, headers, bodyBytes) + resp, err := req.Response() + if err != nil { + return transfer.NewStatus(transfer.StatusInternalServerError), err + } + if resp.StatusCode != http.StatusOK { + return transfer.NewStatus(uint32(resp.StatusCode), http.StatusText(resp.StatusCode)), statusCodeToErr(resp.StatusCode) + } + return transfer.SuccessStatus(), nil +} + +// LockBackend implements transfer.Backend. +func (g *GiteaBackend) LockBackend(_ transfer.Args) transfer.LockBackend { + return newGiteaLockBackend(g) +} diff --git a/modules/lfstransfer/backend/lock.go b/modules/lfstransfer/backend/lock.go new file mode 100644 index 0000000000000..f72ffd5b6f96d --- /dev/null +++ b/modules/lfstransfer/backend/lock.go @@ -0,0 +1,296 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package backend + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" + + "code.gitea.io/gitea/modules/json" + lfslock "code.gitea.io/gitea/modules/structs" + + "github.com/charmbracelet/git-lfs-transfer/transfer" +) + +var _ transfer.LockBackend = &giteaLockBackend{} + +type giteaLockBackend struct { + ctx context.Context + g *GiteaBackend + server *url.URL + token string + itoken string + logger transfer.Logger +} + +func newGiteaLockBackend(g *GiteaBackend) transfer.LockBackend { + server := g.server.JoinPath("locks") + return &giteaLockBackend{ctx: g.ctx, g: g, server: server, token: g.token, itoken: g.itoken, logger: g.logger} +} + +// Create implements transfer.LockBackend +func (g *giteaLockBackend) Create(path, refname string) (transfer.Lock, error) { + reqBody := lfslock.LFSLockRequest{Path: path} + + bodyBytes, err := json.Marshal(reqBody) + if err != nil { + g.logger.Log("json marshal error", err) + return nil, err + } + url := g.server.String() + headers := map[string]string{ + headerAuthorisation: g.itoken, + headerAuthX: g.token, + headerAccept: mimeGitLFS, + headerContentType: mimeGitLFS, + } + req := newInternalRequest(g.ctx, url, http.MethodPost, headers, bodyBytes) + resp, err := req.Response() + if err != nil { + g.logger.Log("http request error", err) + return nil, err + } + defer resp.Body.Close() + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + g.logger.Log("http read error", err) + return nil, err + } + if resp.StatusCode != http.StatusCreated { + g.logger.Log("http statuscode error", resp.StatusCode, statusCodeToErr(resp.StatusCode)) + return nil, statusCodeToErr(resp.StatusCode) + } + var respBody lfslock.LFSLockResponse + err = json.Unmarshal(respBytes, &respBody) + if err != nil { + g.logger.Log("json umarshal error", err) + return nil, err + } + + if respBody.Lock == nil { + g.logger.Log("api returned nil lock") + return nil, fmt.Errorf("api returned nil lock") + } + respLock := respBody.Lock + owner := userUnknown + if respLock.Owner != nil { + owner = respLock.Owner.Name + } + lock := newGiteaLock(g, respLock.ID, respLock.Path, respLock.LockedAt, owner) + return lock, nil +} + +// Unlock implements transfer.LockBackend +func (g *giteaLockBackend) Unlock(lock transfer.Lock) error { + reqBody := lfslock.LFSLockDeleteRequest{} + + bodyBytes, err := json.Marshal(reqBody) + if err != nil { + g.logger.Log("json marshal error", err) + return err + } + url := g.server.JoinPath(lock.ID(), "unlock").String() + headers := map[string]string{ + headerAuthorisation: g.itoken, + headerAuthX: g.token, + headerAccept: mimeGitLFS, + headerContentType: mimeGitLFS, + } + req := newInternalRequest(g.ctx, url, http.MethodPost, headers, bodyBytes) + resp, err := req.Response() + if err != nil { + g.logger.Log("http request error", err) + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + g.logger.Log("http statuscode error", resp.StatusCode, statusCodeToErr(resp.StatusCode)) + return statusCodeToErr(resp.StatusCode) + } + // no need to read response + + return nil +} + +// FromPath implements transfer.LockBackend +func (g *giteaLockBackend) FromPath(path string) (transfer.Lock, error) { + v := url.Values{ + argPath: []string{path}, + } + + respLocks, _, err := g.queryLocks(v) + if err != nil { + return nil, err + } + + if len(respLocks) == 0 { + return nil, transfer.ErrNotFound + } + return respLocks[0], nil +} + +// FromID implements transfer.LockBackend +func (g *giteaLockBackend) FromID(id string) (transfer.Lock, error) { + v := url.Values{ + argID: []string{id}, + } + + respLocks, _, err := g.queryLocks(v) + if err != nil { + return nil, err + } + + if len(respLocks) == 0 { + return nil, transfer.ErrNotFound + } + return respLocks[0], nil +} + +// Range implements transfer.LockBackend +func (g *giteaLockBackend) Range(cursor string, limit int, iter func(transfer.Lock) error) (string, error) { + v := url.Values{ + argLimit: []string{strconv.FormatInt(int64(limit), 10)}, + } + if cursor != "" { + v[argCursor] = []string{cursor} + } + + respLocks, cursor, err := g.queryLocks(v) + if err != nil { + return "", err + } + + for _, lock := range respLocks { + err := iter(lock) + if err != nil { + return "", err + } + } + return cursor, nil +} + +func (g *giteaLockBackend) queryLocks(v url.Values) ([]transfer.Lock, string, error) { + urlq := g.server.JoinPath() // get a copy + urlq.RawQuery = v.Encode() + url := urlq.String() + headers := map[string]string{ + headerAuthorisation: g.itoken, + headerAuthX: g.token, + headerAccept: mimeGitLFS, + headerContentType: mimeGitLFS, + } + req := newInternalRequest(g.ctx, url, http.MethodGet, headers, nil) + resp, err := req.Response() + if err != nil { + g.logger.Log("http request error", err) + return nil, "", err + } + defer resp.Body.Close() + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + g.logger.Log("http read error", err) + return nil, "", err + } + if resp.StatusCode != http.StatusOK { + g.logger.Log("http statuscode error", resp.StatusCode, statusCodeToErr(resp.StatusCode)) + return nil, "", statusCodeToErr(resp.StatusCode) + } + var respBody lfslock.LFSLockList + err = json.Unmarshal(respBytes, &respBody) + if err != nil { + g.logger.Log("json umarshal error", err) + return nil, "", err + } + + respLocks := make([]transfer.Lock, 0, len(respBody.Locks)) + for _, respLock := range respBody.Locks { + owner := userUnknown + if respLock.Owner != nil { + owner = respLock.Owner.Name + } + lock := newGiteaLock(g, respLock.ID, respLock.Path, respLock.LockedAt, owner) + respLocks = append(respLocks, lock) + } + return respLocks, respBody.Next, nil +} + +var _ transfer.Lock = &giteaLock{} + +type giteaLock struct { + g *giteaLockBackend + id string + path string + lockedAt time.Time + owner string +} + +func newGiteaLock(g *giteaLockBackend, id, path string, lockedAt time.Time, owner string) transfer.Lock { + return &giteaLock{g: g, id: id, path: path, lockedAt: lockedAt, owner: owner} +} + +// Unlock implements transfer.Lock +func (g *giteaLock) Unlock() error { + return g.g.Unlock(g) +} + +// ID implements transfer.Lock +func (g *giteaLock) ID() string { + return g.id +} + +// Path implements transfer.Lock +func (g *giteaLock) Path() string { + return g.path +} + +// FormattedTimestamp implements transfer.Lock +func (g *giteaLock) FormattedTimestamp() string { + return g.lockedAt.UTC().Format(time.RFC3339) +} + +// OwnerName implements transfer.Lock +func (g *giteaLock) OwnerName() string { + return g.owner +} + +func (g *giteaLock) CurrentUser() (string, error) { + return userSelf, nil +} + +// AsLockSpec implements transfer.Lock +func (g *giteaLock) AsLockSpec(ownerID bool) ([]string, error) { + msgs := []string{ + fmt.Sprintf("lock %s", g.ID()), + fmt.Sprintf("path %s %s", g.ID(), g.Path()), + fmt.Sprintf("locked-at %s %s", g.ID(), g.FormattedTimestamp()), + fmt.Sprintf("ownername %s %s", g.ID(), g.OwnerName()), + } + if ownerID { + user, err := g.CurrentUser() + if err != nil { + return nil, fmt.Errorf("error getting current user: %w", err) + } + who := "theirs" + if user == g.OwnerName() { + who = "ours" + } + msgs = append(msgs, fmt.Sprintf("owner %s %s", g.ID(), who)) + } + return msgs, nil +} + +// AsArguments implements transfer.Lock +func (g *giteaLock) AsArguments() []string { + return []string{ + fmt.Sprintf("id=%s", g.ID()), + fmt.Sprintf("path=%s", g.Path()), + fmt.Sprintf("locked-at=%s", g.FormattedTimestamp()), + fmt.Sprintf("ownername=%s", g.OwnerName()), + } +} diff --git a/modules/lfstransfer/backend/util.go b/modules/lfstransfer/backend/util.go new file mode 100644 index 0000000000000..126ac001753db --- /dev/null +++ b/modules/lfstransfer/backend/util.go @@ -0,0 +1,141 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package backend + +import ( + "context" + "crypto/tls" + "fmt" + "net" + "net/http" + "time" + + "code.gitea.io/gitea/modules/httplib" + "code.gitea.io/gitea/modules/proxyprotocol" + "code.gitea.io/gitea/modules/setting" + + "github.com/charmbracelet/git-lfs-transfer/transfer" +) + +// HTTP headers +const ( + headerAccept = "Accept" + headerAuthorisation = "Authorization" + headerAuthX = "X-Auth" + headerContentType = "Content-Type" + headerContentLength = "Content-Length" +) + +// MIME types +const ( + mimeGitLFS = "application/vnd.git-lfs+json" + mimeOctetStream = "application/octet-stream" +) + +// SSH protocol action keys +const ( + actionDownload = "download" + actionUpload = "upload" + actionVerify = "verify" +) + +// SSH protocol argument keys +const ( + argCursor = "cursor" + argExpiresAt = "expires-at" + argID = "id" + argLimit = "limit" + argPath = "path" + argRefname = "refname" + argToken = "token" + argTransfer = "transfer" +) + +// Default username constants +const ( + userSelf = "(self)" + userUnknown = "(unknown)" +) + +// Operations enum +const ( + opNone = iota + opDownload + opUpload +) + +var opMap = map[string]int{ + "download": opDownload, + "upload": opUpload, +} + +var ErrMissingID = fmt.Errorf("%w: missing id arg", transfer.ErrMissingData) + +func statusCodeToErr(code int) error { + switch code { + case http.StatusBadRequest: + return transfer.ErrParseError + case http.StatusConflict: + return transfer.ErrConflict + case http.StatusForbidden: + return transfer.ErrForbidden + case http.StatusNotFound: + return transfer.ErrNotFound + case http.StatusUnauthorized: + return transfer.ErrUnauthorized + default: + return fmt.Errorf("server returned status %v: %v", code, http.StatusText(code)) + } +} + +func newInternalRequest(ctx context.Context, url, method string, headers map[string]string, body []byte) *httplib.Request { + req := httplib.NewRequest(url, method). + SetContext(ctx). + SetTimeout(10*time.Second, 60*time.Second). + SetTLSClientConfig(&tls.Config{ + InsecureSkipVerify: true, + }) + + if setting.Protocol == setting.HTTPUnix { + req.SetTransport(&http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + var d net.Dialer + conn, err := d.DialContext(ctx, "unix", setting.HTTPAddr) + if err != nil { + return conn, err + } + if setting.LocalUseProxyProtocol { + if err = proxyprotocol.WriteLocalHeader(conn); err != nil { + _ = conn.Close() + return nil, err + } + } + return conn, err + }, + }) + } else if setting.LocalUseProxyProtocol { + req.SetTransport(&http.Transport{ + DialContext: func(ctx context.Context, network, address string) (net.Conn, error) { + var d net.Dialer + conn, err := d.DialContext(ctx, network, address) + if err != nil { + return conn, err + } + if err = proxyprotocol.WriteLocalHeader(conn); err != nil { + _ = conn.Close() + return nil, err + } + return conn, err + }, + }) + } + + for k, v := range headers { + req.Header(k, v) + } + + req.Body(body) + + return req +} diff --git a/modules/lfstransfer/logger.go b/modules/lfstransfer/logger.go new file mode 100644 index 0000000000000..517c2d9ba135e --- /dev/null +++ b/modules/lfstransfer/logger.go @@ -0,0 +1,21 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package lfstransfer + +import ( + "github.com/charmbracelet/git-lfs-transfer/transfer" +) + +var _ transfer.Logger = (*GiteaLogger)(nil) + +// noop logger for passing into transfer +type GiteaLogger struct{} + +func newLogger() transfer.Logger { + return &GiteaLogger{} +} + +// Log implements transfer.Logger +func (g *GiteaLogger) Log(msg string, itms ...any) { +} diff --git a/modules/lfstransfer/main.go b/modules/lfstransfer/main.go new file mode 100644 index 0000000000000..a134f50b86483 --- /dev/null +++ b/modules/lfstransfer/main.go @@ -0,0 +1,42 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package lfstransfer + +import ( + "context" + "fmt" + "os" + + "code.gitea.io/gitea/modules/lfstransfer/backend" + + "github.com/charmbracelet/git-lfs-transfer/transfer" +) + +func Main(ctx context.Context, repo, verb, token string) error { + logger := newLogger() + pktline := transfer.NewPktline(os.Stdin, os.Stdout, logger) + giteaBackend, err := backend.New(ctx, repo, verb, token, logger) + if err != nil { + return err + } + + for _, cap := range backend.Capabilities { + if err := pktline.WritePacketText(cap); err != nil { + logger.Log("error sending capability due to error:", err) + } + } + if err := pktline.WriteFlush(); err != nil { + logger.Log("error flushing capabilities:", err) + } + p := transfer.NewProcessor(pktline, giteaBackend, logger) + defer logger.Log("done processing commands") + switch verb { + case "upload": + return p.ProcessCommands(transfer.UploadOperation) + case "download": + return p.ProcessCommands(transfer.DownloadOperation) + default: + return fmt.Errorf("unknown operation %q", verb) + } +} diff --git a/modules/setting/lfs.go b/modules/setting/lfs.go index 2034ef782c22c..6bdcbed91d90f 100644 --- a/modules/setting/lfs.go +++ b/modules/setting/lfs.go @@ -13,6 +13,7 @@ import ( // LFS represents the configuration for Git LFS var LFS = struct { StartServer bool `ini:"LFS_START_SERVER"` + AllowPureSSH bool `ini:"LFS_ALLOW_PURE_SSH"` JWTSecretBytes []byte `ini:"-"` HTTPAuthExpiry time.Duration `ini:"LFS_HTTP_AUTH_EXPIRY"` MaxFileSize int64 `ini:"LFS_MAX_FILE_SIZE"` diff --git a/routers/private/internal.go b/routers/private/internal.go index 61e604b7a932e..f9adff388cfd0 100644 --- a/routers/private/internal.go +++ b/routers/private/internal.go @@ -12,7 +12,9 @@ import ( "code.gitea.io/gitea/modules/private" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/common" "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/lfs" "gitea.com/go-chi/binding" chi_middleware "github.com/go-chi/chi/v5/middleware" @@ -46,6 +48,14 @@ func bind[T any](_ T) any { } } +// SwapAuthToken swaps Authorization header with X-Auth header +func swapAuthToken(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + req.Header.Set("Authorization", req.Header.Get("X-Auth")) + next.ServeHTTP(w, req) + }) +} + // Routes registers all internal APIs routes to web application. // These APIs will be invoked by internal commands for example `gitea serv` and etc. func Routes() *web.Router { @@ -80,5 +90,25 @@ func Routes() *web.Router { r.Post("/restore_repo", RestoreRepo) r.Post("/actions/generate_actions_runner_token", GenerateActionsRunnerToken) + r.Group("/repo/{username}/{reponame}", func() { + r.Group("/info/lfs", func() { + r.Post("/objects/batch", lfs.CheckAcceptMediaType, lfs.BatchHandler) + r.Put("/objects/{oid}/{size}", lfs.UploadHandler) + r.Get("/objects/{oid}/{filename}", lfs.DownloadHandler) + r.Get("/objects/{oid}", lfs.DownloadHandler) + r.Post("/verify", lfs.CheckAcceptMediaType, lfs.VerifyHandler) + r.Group("/locks", func() { + r.Get("/", lfs.GetListLockHandler) + r.Post("/", lfs.PostLockHandler) + r.Post("/verify", lfs.VerifyLockHandler) + r.Post("/{lid}/unlock", lfs.UnLockHandler) + }, lfs.CheckAcceptMediaType) + r.Any("/*", func(ctx *context.Context) { + ctx.NotFound("", nil) + }) + }, swapAuthToken) + }, common.Sessioner(), context.Contexter()) + // end "/repo/{username}/{reponame}": git (LFS) API mirror + return r } diff --git a/routers/private/serv.go b/routers/private/serv.go index dbb28cc2bb072..4dd7d06fb36e2 100644 --- a/routers/private/serv.go +++ b/routers/private/serv.go @@ -136,16 +136,15 @@ func ServCommand(ctx *context.PrivateContext) { if err != nil { if repo_model.IsErrRepoNotExist(err) { repoExist = false - for _, verb := range ctx.FormStrings("verb") { - if verb == "git-upload-pack" { - // User is fetching/cloning a non-existent repository - log.Warn("Failed authentication attempt (cannot find repository: %s/%s) from %s", results.OwnerName, results.RepoName, ctx.RemoteAddr()) - ctx.JSON(http.StatusNotFound, private.Response{ - UserMsg: fmt.Sprintf("Cannot find repository: %s/%s", results.OwnerName, results.RepoName), - }) - return - } + if mode == perm.AccessModeRead { + // User is fetching/cloning a non-existent repository + log.Warn("Failed authentication attempt (cannot find repository: %s/%s) from %s", results.OwnerName, results.RepoName, ctx.RemoteAddr()) + ctx.JSON(http.StatusNotFound, private.Response{ + UserMsg: fmt.Sprintf("Cannot find repository: %s/%s", results.OwnerName, results.RepoName), + }) + return } + // else fallthrough (push-to-create may kick in below) } else { log.Error("Unable to get repository: %s/%s Error: %v", results.OwnerName, results.RepoName, err) ctx.JSON(http.StatusInternalServerError, private.Response{ From ad749fbf259fe66e11359e78e859ee305cc59465 Mon Sep 17 00:00:00 2001 From: GiteaBot Date: Sat, 28 Sep 2024 00:30:56 +0000 Subject: [PATCH 2/3] [skip ci] Updated translations via Crowdin --- options/locale/locale_fr-FR.ini | 104 +++++++++++++++++++++++++++++++- 1 file changed, 101 insertions(+), 3 deletions(-) diff --git a/options/locale/locale_fr-FR.ini b/options/locale/locale_fr-FR.ini index a61a7f7ebcc45..1ce04640e3457 100644 --- a/options/locale/locale_fr-FR.ini +++ b/options/locale/locale_fr-FR.ini @@ -31,6 +31,7 @@ username=Nom d'utilisateur email=Courriel password=Mot de passe access_token=Jeton d’accès +re_type=Confirmez le mot de passe captcha=CAPTCHA twofa=Authentification à deux facteurs twofa_scratch=Code de secours pour l'authentification à deux facteurs @@ -158,6 +159,7 @@ filter.public=Public filter.private=Privé no_results_found=Aucun résultat trouvé. +internal_error_skipped=Une erreur interne est survenue, mais ignorée : %s [search] search=Rechercher… @@ -176,6 +178,8 @@ code_search_by_git_grep=Les résultats de recherche de code actuels sont fournis package_kind=Chercher des paquets… project_kind=Chercher des projets… branch_kind=Chercher des branches… +tag_kind=Chercher des étiquettes… +tag_tooltip=Cherchez des étiquettes correspondantes. Utilisez « % » pour rechercher n’importe quelle suite de nombres. commit_kind=Chercher des révisions… runner_kind=Chercher des exécuteurs… no_results=Aucun résultat correspondant trouvé. @@ -217,16 +221,20 @@ string.desc=Z - A [error] occurred=Une erreur s’est produite +report_message=Si vous pensez qu’il s’agit d’un bug Gitea, veuillez consulter notre board GitHub ou ouvrir un nouveau ticket si nécessaire. not_found=La cible n'a pu être trouvée. network_error=Erreur réseau [startpage] app_desc=Un service Git auto-hébergé sans prise de tête install=Facile à installer +install_desc=Il suffit de lancer l’exécutable adapté à votre plateforme, le déployer avec Docker ou de l’installer depuis un gestionnaire de paquet. platform=Multi-plateforme +platform_desc=Gitea tourne partout où Go peut être compilé : Windows, macOS, Linux, ARM, etc. Choisissez votre préféré ! lightweight=Léger lightweight_desc=Gitea utilise peu de ressources. Il peut même tourner sur un Raspberry Pi très bon marché. Économisez l'énergie de vos serveurs ! license=Open Source +license_desc=Venez récupérer %[2]s ! Rejoignez-nous en contribuant à rendre ce projet encore meilleur ! [install] install=Installation @@ -310,6 +318,7 @@ admin_setting_desc=La création d'un compte administrateur est facultative. Le p admin_title=Paramètres de compte administrateur admin_name=Nom d’utilisateur administrateur admin_password=Mot de passe +confirm_password=Confirmez le mot de passe admin_email=Courriel install_btn_confirm=Installer Gitea test_git_failed=Le test de la commande "git" a échoué : %v @@ -448,6 +457,7 @@ authorize_title=Autoriser "%s" à accéder à votre compte ? authorization_failed=L’autorisation a échoué authorization_failed_desc=L'autorisation a échoué car nous avons détecté une demande incorrecte. Veuillez contacter le responsable de l'application que vous avez essayé d'autoriser. sspi_auth_failed=Échec de l'authentification SSPI +password_pwned=Le mot de passe que vous avez choisi fait partit des mots de passe ayant fuité sur internet. Veuillez réessayer avec un mot de passe différent et considérez remplacer ce mot de passe si vous l’utilisez ailleurs. password_pwned_err=Impossible d'envoyer la demande à HaveIBeenPwned last_admin=Vous ne pouvez pas supprimer ce compte car au moins un administrateur est requis. signin_passkey=Se connecter avec une clé d’identification (passkey) @@ -531,6 +541,7 @@ UserName=Nom d'utilisateur RepoName=Nom du dépôt Email=Courriel Password=Mot de passe +Retype=Confirmez le mot de passe SSHTitle=Nom de la clé SSH HttpsUrl=URL HTTPS PayloadUrl=URL des données utiles @@ -684,9 +695,11 @@ applications=Applications orgs=Gérer les organisations repos=Dépôts delete=Supprimer le compte +twofa=Authentification à deux facteurs (TOTP) account_link=Comptes liés organization=Organisations uid=UID +webauthn=Authentification à deux facteurs (Clés de sécurité) public_profile=Profil public biography_placeholder=Parlez-nous un peu de vous ! (Vous pouvez utiliser Markdown) @@ -784,6 +797,7 @@ 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 +keep_email_private_popup=Ceci masquera votre adresse e-mail de votre profil, de vos demandes d’ajout et des fichiers modifiés depuis l'interface Web. Les révisions déjà soumises ne seront pas modifiés. Utilisez %s dans les révisions pour les associer à votre compte. openid_desc=OpenID vous permet de confier l'authentification à une tierce partie. manage_ssh_keys=Gérer les clés SSH @@ -922,20 +936,26 @@ 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=Accès révoqué avec succès. +twofa_desc=Pour protéger votre compte contre les vols de mot de passes, vous pouvez utiliser un smartphone ou autres appareils pour recevoir un code temporaire à usage unique (TOTP). twofa_recovery_tip=Si vous perdez votre appareil, vous pourrez utiliser une clé de récupération à usage unique pour obtenir l’accès à votre compte. twofa_is_enrolled=Votre compte est inscrit à l'authentification à deux facteurs. twofa_not_enrolled=Votre compte n'est pas inscrit à l'authentification à deux facteurs. twofa_disable=Désactiver l'authentification à deux facteurs +twofa_scratch_token_regenerate=Régénérer une clé de secours à usage unique +twofa_scratch_token_regenerated=Votre clé de secours à usage unique est désormais « %s ». Stockez-la dans un endroit sûr, elle ne sera plus jamais affichée. 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 ? +regenerate_scratch_token_desc=Si vous avez égaré votre clé de secours ou avez dû l’utiliser pour vous authentifier, vous pouvez la régénérer. twofa_disabled=L'authentification à deux facteurs a été désactivée. scan_this_image=Scannez cette image avec votre application d'authentification : or_enter_secret=Ou saisissez le code %s then_enter_passcode=Et entrez le code de passe s'affichant dans l'application : passcode_invalid=Le mot de passe est invalide. Réessayez. +twofa_enrolled=L’authentification à deux facteurs a été activée pour votre compte. Gardez votre clé de secours (%s) en lieu sûr, car il ne vous sera montré qu'une seule fois. twofa_failed_get_secret=Impossible d'obtenir le secret. +webauthn_desc=Les clefs de sécurité sont des dispositifs matériels contenant des clefs cryptographiques. Elles peuvent être utilisées pour l’authentification à deux facteurs. La clef de sécurité doit supporter le standard WebAuthn Authenticator. webauthn_register_key=Ajouter une clé de sécurité webauthn_nickname=Pseudonyme webauthn_delete_key=Retirer la clé de sécurité @@ -1080,7 +1100,9 @@ tree_path_not_found_branch=Le chemin %[1]s n’existe pas dans la branche %[2]s. tree_path_not_found_tag=Le chemin %[1]s n’existe pas dans l’étiquette %[2]s. transfer.accept=Accepter le transfert +transfer.accept_desc=Transférer à « %s » transfer.reject=Refuser le transfert +transfer.reject_desc=Annuler le transfert à « %s » transfer.no_permission_to_accept=Vous n’êtes pas autorisé à accepter ce transfert. transfer.no_permission_to_reject=Vous n’êtes pas autorisé à rejeter ce transfert. @@ -1155,6 +1177,11 @@ migrate.gogs.description=Migrer les données depuis notabug.org ou d’autres in migrate.onedev.description=Migrer les données depuis code.onedev.io ou d’autre instance de OneDev. migrate.codebase.description=Migrer les données depuis codebasehq.com. migrate.gitbucket.description=Migrer les données depuis des instances GitBucket. +migrate.codecommit.description=Migrer les données depuis AWS CodeCommit. +migrate.codecommit.aws_access_key_id=ID de la clé d’accès AWS +migrate.codecommit.aws_secret_access_key=Clé d’accès secrète AWS +migrate.codecommit.https_git_credentials_username=Nom d’utilisateur Git HTTPS +migrate.codecommit.https_git_credentials_password=Mot de passe Git HTTPS migrate.migrating_git=Migration des données Git migrate.migrating_topics=Migration des sujets migrate.migrating_milestones=Migration des jalons @@ -1215,6 +1242,7 @@ releases=Publications tag=Étiquette released_this=a publié ceci tagged_this=a étiqueté +file.title=%s sur %s file_raw=Brut file_history=Historique file_view_source=Voir le code source @@ -1454,6 +1482,7 @@ issues.remove_labels=a supprimé les labels %s %s. issues.add_remove_labels=a ajouté le label %s et supprimé %s %s. issues.add_milestone_at=`a ajouté ça au jalon %s %s.` issues.add_project_at=`a ajouté ça au projet %s %s.` +issues.move_to_column_of_project=`a déplacé ça vers %s dans %s sur %s` issues.change_milestone_at=`a remplacé le jalon %s par %s %s.` issues.change_project_at=`a remplacé le projet %s par %s %s.` issues.remove_milestone_at=`a supprimé ça du jalon %s %s.` @@ -1702,9 +1731,10 @@ issues.dependency.add_error_dep_not_same_repo=Les deux tickets doivent être dan issues.review.self.approval=Vous ne pouvez approuver vos propres demandes d'ajout. issues.review.self.rejection=Vous ne pouvez demander de changements sur vos propres demandes de changement. issues.review.approve=a approuvé ces modifications %s. +issues.review.comment=a évalué %s issues.review.dismissed=a révoqué l’évaluation de %s %s. issues.review.dismissed_label=Révoquée -issues.review.left_comment=laisser un commentaire +issues.review.left_comment=à laissé un commentaire issues.review.content.empty=Vous devez laisser un commentaire indiquant le(s) changement(s) demandé(s). issues.review.reject=a requis les changements %s issues.review.wait=a été sollicité pour évaluer cette demande d’ajout %s. @@ -1726,7 +1756,12 @@ issues.review.hide_resolved=Réduire issues.review.resolve_conversation=Clore la conversation issues.review.un_resolve_conversation=Rouvrir la conversation issues.review.resolved_by=a marqué cette conversation comme résolue. -issues.review.commented=Commenter +issues.review.commented=À commenté +issues.review.official=Approuvée +issues.review.requested=Évaluation en attente +issues.review.rejected=Changements demandées +issues.review.stale=Modifiée depuis la dernière approbation +issues.review.unofficial=Approbation non comptabilisée 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=a supprimé @@ -1800,6 +1835,8 @@ pulls.is_empty=Les changements sur cette branche sont déjà sur la branche cibl 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. +pulls.blocked_by_approvals=Cette demande d'ajout n’est pas suffisamment approuvée. %d approbations obtenues sur %d. +pulls.blocked_by_approvals_whitelisted=Cette demande d’ajout n’a pas encore assez d’approbations. %d sur %d approbations de la part des utilisateurs ou équipes sur la liste autorisée. pulls.blocked_by_rejection=Cette demande d’ajout nécessite des corrections sollicitées par un évaluateur officiel. pulls.blocked_by_official_review_requests=Cette demande d’ajout a des sollicitations officielles d’évaluation. pulls.blocked_by_outdated_branch=Cette demande d’ajout est bloquée car elle est obsolète. @@ -1841,7 +1878,9 @@ pulls.unrelated_histories=Échec de la fusion: La tête de fusion et la base ne pulls.merge_out_of_date=Échec de la fusion: La base a été mise à jour en cours de fusion. Indice : Réessayez. pulls.head_out_of_date=Échec de la fusion : L’en-tête a été mis à jour pendant la fusion. Conseil : réessayez. pulls.has_merged=Échec : La demande d’ajout est déjà fusionnée, vous ne pouvez plus la fusionner, ni modifier sa branche cible. +pulls.push_rejected=Échec de la fusion : la soumission a été rejetée. Revoyez les déclencheurs Git pour ce dépôt. pulls.push_rejected_summary=Message de rejet complet +pulls.push_rejected_no_message=Échec de la fusion : la soumission a été rejetée sans raison. Revoyez les déclencheurs Git pour ce dépôt. pulls.open_unmerged_pull_exists=`Vous ne pouvez pas rouvrir ceci car la demande d’ajout #%d, en attente, a des propriétés identiques.` pulls.status_checking=Certains contrôles sont en attente pulls.status_checks_success=Tous les contrôles ont réussi @@ -1865,6 +1904,7 @@ pulls.cmd_instruction_checkout_title=Basculer pulls.cmd_instruction_checkout_desc=Depuis votre dépôt, basculer sur une nouvelle branche et tester des modifications. pulls.cmd_instruction_merge_title=Fusionner pulls.cmd_instruction_merge_desc=Fusionner les modifications et mettre à jour sur Gitea. +pulls.cmd_instruction_merge_warning=Attention : cette opération ne peut pas fusionner la demande d’ajout car la « détection automatique de fusion manuelle » n’a pas été activée pulls.clear_merge_message=Effacer le message de fusion pulls.clear_merge_message_hint=Effacer le message de fusion ne supprimera que le message de la révision, mais pas les pieds de révision générés tels que "Co-Authored-By:". @@ -1886,6 +1926,7 @@ pulls.delete.text=Voulez-vous vraiment supprimer cet demande d'ajout ? (Cela sup pulls.recently_pushed_new_branches=Vous avez soumis sur la branche %[1]s %[2]s pull.deleted_branch=(supprimé) : %s +pull.agit_documentation=Voir la documentation sur AGit comments.edit.already_changed=Impossible d’enregistrer ce commentaire. Il semble que le contenu ait été modifié par un autre utilisateur. Veuillez rafraîchir la page et réessayer afin d’éviter d’écraser leurs modifications. @@ -1896,6 +1937,7 @@ milestones.no_due_date=Aucune date d'échéance milestones.open=Ouvrir milestones.close=Fermer milestones.new_subheader=Les jalons peuvent vous aider à organiser vos tickets et à suivre leurs progrès. +milestones.completeness=%d%% complété milestones.create=Créer un Jalon milestones.title=Titre milestones.desc=Description @@ -2080,7 +2122,8 @@ settings.push_mirror_sync_in_progress=Versement des changements vers le miroir d settings.site=Site Web settings.update_settings=Appliquer settings.update_mirror_settings=Mettre à jour les paramètres du miroir -settings.branches.update_default_branch=Changer la Branche par Défaut +settings.branches.switch_default_branch=Changer la branche par défaut +settings.branches.update_default_branch=Changer la branche par défaut settings.branches.add_new_rule=Ajouter une nouvelle règle settings.advanced_settings=Paramètres avancés settings.wiki_desc=Activer le wiki du dépôt @@ -2117,6 +2160,7 @@ settings.pulls.default_delete_branch_after_merge=Supprimer la branche après la settings.pulls.default_allow_edits_from_maintainers=Autoriser les modifications par les mainteneurs par défaut settings.releases_desc=Activer les publications du dépôt settings.packages_desc=Activer le registre des paquets du dépôt +settings.projects_desc=Activer les projets de dépôt settings.projects_mode_desc=Mode Projets (type de projets à afficher) settings.projects_mode_repo=Projets de dépôt uniquement settings.projects_mode_owner=Projets d’utilisateur ou d’organisation uniquement @@ -2156,6 +2200,7 @@ settings.transfer_in_progress=Il y a actuellement un transfert en cours. Veuille settings.transfer_notices_1=- Vous perdrez l'accès à ce dépôt si vous le transférez à un autre utilisateur. settings.transfer_notices_2=- Vous conserverez l'accès à ce dépôt si vous le transférez à une organisation dont vous êtes (co-)propriétaire. settings.transfer_notices_3=- Si le dépôt est privé et est transféré à un utilisateur individuel, cette action s'assure que l'utilisateur a au moins la permission de lire (et modifie les permissions si nécessaire). +settings.transfer_notices_4=- Si le dépôt appartient à une organisation et que vous le transférez à une autre organisation ou personne, vous perdrez les liens entre les tickets du dépôt et le tableau de projet de l’organisation. settings.transfer_owner=Nouveau propriétaire settings.transfer_perform=Effectuer le transfert settings.transfer_started=`Ce dépôt a été marqué pour le transfert et attend la confirmation de "%s"` @@ -2292,6 +2337,7 @@ settings.event_pull_request_merge=Fusion de demande d'ajout settings.event_package=Paquet settings.event_package_desc=Paquet créé ou supprimé. settings.branch_filter=Filtre de branche +settings.branch_filter_desc=Liste de branches et motifs globs autorisant la soumission, la création et suppression de branches. Laisser vide ou utiliser * englobent toutes les branches. Voir la %[2]s. 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 @@ -2337,9 +2383,13 @@ 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 soumission ? +settings.protected_branch_can_push_yes=Vous pouvez soumettre +settings.protected_branch_can_push_no=Vous ne pouvez pas soumettre +settings.branch_protection=Paramètres de protection de branches pour la branche %s settings.protect_this_branch=Activer la protection de branche settings.protect_this_branch_desc=Empêche les suppressions et limite les poussées et fusions sur cette branche. settings.protect_disable_push=Désactiver la soumission @@ -2369,11 +2419,13 @@ settings.protect_merge_whitelist_teams=Équipes autorisées à fusionner : settings.protect_check_status_contexts=Activer le Contrôle Qualité settings.protect_status_check_patterns=Motifs de vérification des statuts : settings.protect_status_check_patterns_desc=Entrez des motifs pour spécifier quelles vérifications doivent réussir avant que des branches puissent être fusionnées. Un motif par ligne. Un motif ne peut être vide. +settings.protect_check_status_contexts_desc=Exiger le status « succès » avant de fusionner. Quand activée, une branche protégée ne peux accepter que des soumissions ou des fusions ayant le status « succès ». Lorsqu'il n’y a pas de contexte, la dernière révision fait foi. settings.protect_check_status_contexts_list=Contrôles qualité trouvés au cours de la semaine dernière pour ce dépôt settings.protect_status_check_matched=Correspondant settings.protect_invalid_status_check_pattern=Motif de vérification des statuts incorrect : « %s ». settings.protect_no_valid_status_check_patterns=Aucun motif de vérification des statuts valide. settings.protect_required_approvals=Minimum d'approbations requis : +settings.protect_required_approvals_desc=Permet de fusionner les demandes d’ajout lorsque suffisamment d’évaluation sont positives. settings.protect_approvals_whitelist_enabled=Restreindre les approbations aux utilisateurs ou aux équipes sur liste d’autorisés settings.protect_approvals_whitelist_enabled_desc=Seuls les évaluations des utilisateurs ou des équipes suivantes compteront dans les approbations requises. Si laissé vide, les évaluations de toute personne ayant un accès en écriture seront comptabilisées à la place. settings.protect_approvals_whitelist_users=Évaluateurs autorisés : @@ -2385,12 +2437,18 @@ settings.ignore_stale_approvals_desc=Ignorer les approbations d’anciennes rév settings.require_signed_commits=Exiger des révisions signées settings.require_signed_commits_desc=Rejeter les soumissions sur cette branche lorsqu'ils ne sont pas signés ou vérifiables. settings.protect_branch_name_pattern=Motif de nom de branche protégé +settings.protect_branch_name_pattern_desc=Motifs de nom de branche protégé. Consultez la documentation pour la syntaxe du motif. Exemples : main, release/** settings.protect_patterns=Motifs settings.protect_protected_file_patterns=Liste des fichiers et motifs protégés +settings.protect_protected_file_patterns_desc=Liste de fichiers et de motifs, séparés par un point-virgule « ; », qui ne pourront pas être modifiés même si les utilisateurs disposent des droits sur la branche. Consultez la %[2]s. Exemples : .drone.yml ; /docs/**/*.txt. settings.protect_unprotected_file_patterns=Liste des fichiers et motifs exclus +settings.protect_unprotected_file_patterns_desc=Liste de fichiers et de motifs globs, séparés par un point-virgule « ; », qui pourront être modifiés malgré la protection de branche, par les utilisateurs autorisés. Voir la %[2]s. 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 en cas d’évaluations négatives settings.block_rejected_reviews_desc=La fusion ne sera pas possible lorsque des modifications sont demandées par les évaluateurs officiels, même s'il y a suffisamment d’approbations. @@ -2400,6 +2458,7 @@ settings.block_outdated_branch=Bloquer la fusion si la demande d'ajout est obsol 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=Styles de fusion +settings.default_merge_style_desc=Méthode de fusion par défaut 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 @@ -2415,12 +2474,25 @@ 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 au choix un nom unique, un motif de glob ou une expression régulière qui correspondra à plusieurs étiquettes. Pour plus d’informations, consultez le guide sur les étiquettes protégées. settings.bot_token=Jeton de Bot settings.chat_id=ID de conversation settings.thread_id=ID du fil settings.matrix.homeserver_url=URL du serveur d'accueil settings.matrix.room_id=ID de la salle settings.matrix.message_type=Type de message +settings.visibility.private.button=Rendre privé +settings.visibility.private.text=Rendre le dépôt privé rendra non seulement le dépôt visible uniquement aux membres autorisés, mais peut également rompre la relation entre lui et ses bifurcations, observateurs, et favoris. +settings.visibility.private.bullet_title=Changer la visibilité en privé : +settings.visibility.private.bullet_one=Va rendre le dépôt visible uniquement par les membres autorisés +settings.visibility.private.bullet_two=Peut supprimer la relation avec ses bifurcations, ses observateurs et ses favoris +settings.visibility.public.button=Rendre public +settings.visibility.public.text=Rendre le dépôt public rendra le dépôt visible à tout le monde. +settings.visibility.public.bullet_title=Changer la visibilité en public va : +settings.visibility.public.bullet_one=Rendre le dépôt visible à tout le monde. +settings.visibility.success=Visibilité du dépôt changée. +settings.visibility.error=Une erreur s’est produite en essayant de changer la visibilité du dépôt. +settings.visibility.fork_error=Impossible de changer la visibilité d’un dépôt bifurqué. settings.archive.button=Archiver ce dépôt settings.archive.header=Archiver ce dépôt settings.archive.text=Archiver un dépôt le place en lecture seule et le cache des tableaux de bord. Personne ne pourra faire de nouvelles révisions, d'ouvrir des tickets ou des demandes d'ajouts (pas même vous!). @@ -2617,6 +2689,7 @@ 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 sujets doivent commencer par un caractère alphanumérique, peuvent inclure des traits d’union « - » et des points « . », et mesurer jusqu'à 35 caractères. Les lettres doivent être en minuscules. find_file.go_to_file=Aller au fichier @@ -2783,6 +2856,7 @@ last_page=Dernière total=Total : %d settings=Paramètres administrateur +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.maintenance_operations=Opérations de maintenance dashboard.system_status=État du système @@ -2825,6 +2899,7 @@ dashboard.reinit_missing_repos=Réinitialiser tous les dépôts Git manquants po 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.cleanup_actions=Nettoyer les reliquats des actions obsolètes dashboard.server_uptime=Uptime du serveur dashboard.current_goroutine=Goroutines actuelles dashboard.current_memory_usage=Utilisation Mémoire actuelle @@ -2854,9 +2929,15 @@ dashboard.total_gc_time=Pause GC 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 activités de la base de données +dashboard.delete_old_actions.started=La suppression des anciennes activités de la base de données a démarré. 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.gc_lfs=Épousseter les métaobjets LFS +dashboard.stop_zombie_tasks=Arrêter les tâches zombies +dashboard.stop_endless_tasks=Arrêter les tâches interminables +dashboard.cancel_abandoned_jobs=Annuler les travaux abandonnés +dashboard.start_schedule_tasks=Démarrer les tâches planifiées dashboard.sync_branch.started=Début de la synchronisation des branches dashboard.sync_tag.started=Synchronisation des étiquettes dashboard.rebuild_issue_indexer=Reconstruire l’indexeur des tickets @@ -2967,10 +3048,12 @@ packages.size=Taille packages.published=Publiés defaulthooks=Déclencheurs web par défaut +defaulthooks.desc=Les webhooks font automatiquement des requêtes POST HTTP à un serveur spécifié lorsque certains événements Gitea se déclenchent. Ceux créés ici sont par défaut copiés sur tous les nouveaux dépôts. Pour plus d'information, consultez le guide des webhooks. defaulthooks.add_webhook=Ajouter un déclencheur web par défaut defaulthooks.update_webhook=Mettre à jour le déclencheur web par défaut systemhooks=Webhooks système +systemhooks.desc=Les webhooks font automatiquement des requêtes POST HTTP à un serveur spécifié lorsque certains événements Gitea se déclenchent. Ceux créé ici agiront sur tous les dépôts, ce qui peux impacter les performances du système. Pour plus d’information, consultez le guide des webhooks. systemhooks.add_webhook=Ajouter un rappel système systemhooks.update_webhook=Mettre à jour un rappel système @@ -3065,8 +3148,18 @@ auths.tips=Conseils auths.tips.oauth2.general=Authentification OAuth2 auths.tips.oauth2.general.tip=Lors de l'enregistrement d'une nouvelle authentification OAuth2, l'URL de rappel/redirection doit être : auths.tip.oauth2_provider=Fournisseur OAuth2 +auths.tip.bitbucket=Créez un nouveau jeton OAuth sur %s et ajoutez la permission « Compte » → « Lecture ». auths.tip.nextcloud=`Enregistrez un nouveau consommateur OAuth sur votre instance en utilisant le menu "Paramètres -> Sécurité -> Client OAuth 2.0"` +auths.tip.dropbox=Créez une nouvelle application sur %s +auths.tip.facebook=Enregistrez une nouvelle application sur%s et ajoutez le produit « Facebook Login ». +auths.tip.github=Créez une nouvelle application OAuth sur %s +auths.tip.gitlab_new=Enregistrez une nouvelle application sur %s +auths.tip.google_plus=Obtenez des identifiants OAuth2 sur la console API de Google (%s) auths.tip.openid_connect=Utilisez l’URL de découverte OpenID « https://{server}/.well-known/openid-configuration » pour spécifier les points d'accès. +auths.tip.twitter=Rendez-vous sur %s, créez une application et assurez-vous que l’option « Autoriser l’application à être utilisée avec Twitter Connect » est activée. +auths.tip.discord=Enregistrer une nouvelle application sur %s +auths.tip.gitea=Enregistrez une nouvelle application OAuth2. Le guide peut être trouvé sur %s. +auths.tip.yandex=Créez une nouvelle application sur %s. Sélectionnez les autorisations suivantes dans la section « Yandex.Passport API » : « Accès à l’adresse e-mail », « Accès à l’avatar de l’utilisateur » et « Accès au nom d’utilisateur, prénom, surnom et genre ». 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 @@ -3240,6 +3333,8 @@ 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 @@ -3341,6 +3436,7 @@ raw_minutes=minutes [dropzone] default_message=Déposez les fichiers ou cliquez ici pour téléverser. +invalid_input_type=Vous ne pouvez pas téléverser des fichiers de ce type. file_too_big=La taille du fichier ({{filesize}} Mo) dépasse la taille maximale ({{maxFilesize}} Mo). remove_file=Supprimer le fichier @@ -3612,6 +3708,7 @@ runs.no_workflows.quick_start=Vous découvrez les Actions Gitea ? Consultez la documentation. runs.no_runs=Le flux de travail n'a pas encore d'exécution. runs.empty_commit_message=(message de révision vide) +runs.expire_log_message=Les journaux ont été supprimés car ils étaient trop anciens. workflow.disable=Désactiver le flux de travail workflow.disable_success=Le flux de travail « %s » a bien été désactivé. @@ -3643,6 +3740,7 @@ variables.update.failed=Impossible d’éditer la variable. variables.update.success=La variable a bien été modifiée. [projects] +deleted.display_name=Projet supprimé type-1.display_name=Projet personnel type-2.display_name=Projet de dépôt type-3.display_name=Projet d’organisation From 99d0510cb69c3c53cee05ef0e83ed02389925a90 Mon Sep 17 00:00:00 2001 From: Bruno Sofiato Date: Sat, 28 Sep 2024 17:13:55 -0300 Subject: [PATCH 3/3] Change the code search to sort results by relevance (#32134) Resolves #32129 Signed-off-by: Bruno Sofiato --- modules/indexer/code/bleve/bleve.go | 2 ++ modules/indexer/code/elasticsearch/elasticsearch.go | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/modules/indexer/code/bleve/bleve.go b/modules/indexer/code/bleve/bleve.go index 542bdfb50141a..c17f56d3cff5a 100644 --- a/modules/indexer/code/bleve/bleve.go +++ b/modules/indexer/code/bleve/bleve.go @@ -284,6 +284,8 @@ func (b *Indexer) Search(ctx context.Context, opts *internal.SearchOptions) (int searchRequest.AddFacet("languages", bleve.NewFacetRequest("Language", 10)) } + searchRequest.SortBy([]string{"-_score", "UpdatedAt"}) + result, err := b.inner.Indexer.SearchInContext(ctx, searchRequest) if err != nil { return 0, nil, nil, err diff --git a/modules/indexer/code/elasticsearch/elasticsearch.go b/modules/indexer/code/elasticsearch/elasticsearch.go index 0bda180fac9ce..d64d99433d989 100644 --- a/modules/indexer/code/elasticsearch/elasticsearch.go +++ b/modules/indexer/code/elasticsearch/elasticsearch.go @@ -318,7 +318,8 @@ func (b *Indexer) Search(ctx context.Context, opts *internal.SearchOptions) (int NumOfFragments(0). // return all highting content on fragments HighlighterType("fvh"), ). - Sort("repo_id", true). + Sort("_score", false). + Sort("updated_at", true). From(start).Size(pageSize). Do(ctx) if err != nil { @@ -349,7 +350,8 @@ func (b *Indexer) Search(ctx context.Context, opts *internal.SearchOptions) (int NumOfFragments(0). // return all highting content on fragments HighlighterType("fvh"), ). - Sort("repo_id", true). + Sort("_score", false). + Sort("updated_at", true). From(start).Size(pageSize). Do(ctx) if err != nil {