Gitfed
bastien-mrq/gitfed/ Commits/ dd1168a

Add one-shot repo import; simplify homepage changelog preview

One-shot import (dashboard, "Import from URL"): paste a source repo's HTTPS URL, gitfed clones it once server-side (git clone --mirror, then detaches the origin remote so it never re-fetches on its own) into a new repo under the caller's own namespace. Restricted to https:// URLs with no embedded credentials, host resolution checked against private/ loopback/link-local addresses (federation.CheckPublicHost, reusing the same anti-SSRF guard as federation discovery) — reachable via the same namespace/quota rules as regular repo creation. Also simplifies the homepage's changelog section (added in 1.1.1) from a rendered 3-entry preview down to a single button linking to /changelog — the preview was more than needed.

bastien-mrq 2026-07-29 08:58 commit dd1168a5a3120b70d91b33fea68d68ad5f7d9fa1 parent 0346da1f4a52cb54fa21b413a4480de3aa5e1a82
12 files changed +254 −100
M cmd/gitfed-web/handlers_dashboard.go +72 −29
M cmd/gitfed-web/handlers_landing.go +5 −42
M cmd/gitfed-web/render.go +2 −3
M cmd/gitfed-web/routes.go +1 −0
M internal/admin/admin.go +64 −0
M internal/adminrpc/client.go +4 −0
M internal/adminrpc/protocol.go +7 −0
M internal/adminrpc/server.go +7 −0
M internal/federation/wellknown.go +25 −0
M internal/gitexec/gitexec.go +33 −0
M internal/i18n/strings_en.go +17 −13
M internal/i18n/strings_fr.go +17 −13
cmd/gitfed-web/handlers_dashboard.go
diff --git a/cmd/gitfed-web/handlers_dashboard.go b/cmd/gitfed-web/handlers_dashboard.go index a799c50..699633e 100644 --- a/cmd/gitfed-web/handlers_dashboard.go +++ b/cmd/gitfed-web/handlers_dashboard.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "errors" "html/template" "net/http" "strings" @@ -20,14 +21,26 @@ var dashboardTpl = newTpl("dashboard", ` <div class="gf-page-head"> <h1>{{t .Lang "dashboard.title"}}</h1> - <details class="gf-new-repo"> - <summary class="gf-btn primary" style="margin:0;">+ {{t .Lang "dashboard.new_repo"}}</summary> - <form class="card" method="post" action="/repos" style="margin-top:0.75rem;"> - <label>{{t .Lang "dashboard.repo_name"}}</label> - <input name="name" required placeholder="{{.Username}}/my-project"> - <button type="submit">{{t .Lang "dashboard.create"}}</button> - </form> - </details> + <div class="gf-page-head-actions"> + <details class="gf-new-repo"> + <summary class="gf-btn" style="margin:0;">+ {{t .Lang "dashboard.import_repo"}}</summary> + <form class="card" method="post" action="/repos/import" style="margin-top:0.75rem;"> + <label>{{t .Lang "dashboard.repo_name"}}</label> + <input name="name" required placeholder="{{.Username}}/my-project"> + <label>{{t .Lang "dashboard.import_url_label"}}</label> + <input name="source_url" type="url" required placeholder="https://github.com/owner/repo.git"> + <button type="submit">{{t .Lang "dashboard.import"}}</button> + </form> + </details> + <details class="gf-new-repo"> + <summary class="gf-btn primary" style="margin:0;">+ {{t .Lang "dashboard.new_repo"}}</summary> + <form class="card" method="post" action="/repos" style="margin-top:0.75rem;"> + <label>{{t .Lang "dashboard.repo_name"}}</label> + <input name="name" required placeholder="{{.Username}}/my-project"> + <button type="submit">{{t .Lang "dashboard.create"}}</button> + </form> + </details> + </div> </div> <div class="gf-card gf-repo-list"> @@ -129,43 +142,52 @@ func (s *server) handleDashboard(w http.ResponseWriter, r *http.Request) { // logged-in user can't exhaust disk by mass-creating repos. const maxReposPerUser = 100 -func (s *server) handleCreateRepo(w http.ResponseWriter, r *http.Request) { - sess, _ := s.currentSession(r) - lang := s.lang(r) - name := strings.Trim(strings.TrimSpace(r.FormValue("name")), "/") +// resolveOwnRepoName validates and normalizes a repo name the way every +// self-service repo-creation path does (new repo, one-shot import): keep it +// inside the creator's own namespace, and check they're under the +// per-account quota. The returned error's message is already translated. +func (s *server) resolveOwnRepoName(sess store.Session, lang i18n.Lang, rawName string) (string, error) { + name := strings.Trim(strings.TrimSpace(rawName), "/") // Keep self-service repos inside the creator's own namespace: a bare name // is prefixed with the username; a "namespace/name" form must use the // user's own namespace. This blocks squatting another user's prefix. if name == "" { - redirectWithMsg(w, r, "/dashboard", i18n.T(lang, "dashboard.msg_name_required"), true) - return + return "", errors.New(i18n.T(lang, "dashboard.msg_name_required")) } if strings.Contains(name, "/") { if !strings.HasPrefix(name, sess.Username+"/") { - redirectWithMsg(w, r, "/dashboard", i18n.T(lang, "dashboard.msg_bad_namespace"), true) - return + return "", errors.New(i18n.T(lang, "dashboard.msg_bad_namespace")) } } else { name = sess.Username + "/" + name } - if repos, err := s.ops.ListRepos(); err != nil { - s.serverError(w, r, err) - return - } else { - owned := 0 - for _, repo := range repos { - if repo.Owner == sess.Principal { - owned++ - } - } - if owned >= maxReposPerUser { - redirectWithMsg(w, r, "/dashboard", i18n.T(lang, "dashboard.msg_quota"), true) - return + repos, err := s.ops.ListRepos() + if err != nil { + return "", err + } + owned := 0 + for _, repo := range repos { + if repo.Owner == sess.Principal { + owned++ } } + if owned >= maxReposPerUser { + return "", errors.New(i18n.T(lang, "dashboard.msg_quota")) + } + return name, nil +} +func (s *server) handleCreateRepo(w http.ResponseWriter, r *http.Request) { + sess, _ := s.currentSession(r) + lang := s.lang(r) + + name, err := s.resolveOwnRepoName(sess, lang, r.FormValue("name")) + if err != nil { + redirectWithMsg(w, r, "/dashboard", err.Error(), true) + return + } if err := s.ops.CreateRepo(name, sess.Username); err != nil { redirectWithMsg(w, r, "/dashboard", err.Error(), true) return @@ -173,6 +195,27 @@ func (s *server) handleCreateRepo(w http.ResponseWriter, r *http.Request) { redirectWithMsg(w, r, "/repo-settings/"+name, i18n.T(lang, "dashboard.msg_created", name), false) } +func (s *server) handleImportRepo(w http.ResponseWriter, r *http.Request) { + sess, _ := s.currentSession(r) + lang := s.lang(r) + + name, err := s.resolveOwnRepoName(sess, lang, r.FormValue("name")) + if err != nil { + redirectWithMsg(w, r, "/dashboard", err.Error(), true) + return + } + sourceURL := strings.TrimSpace(r.FormValue("source_url")) + if sourceURL == "" { + redirectWithMsg(w, r, "/dashboard", i18n.T(lang, "dashboard.msg_import_url_required"), true) + return + } + if err := s.ops.ImportRepo(name, sourceURL, sess.Username); err != nil { + redirectWithMsg(w, r, "/dashboard", err.Error(), true) + return + } + redirectWithMsg(w, r, "/repo-settings/"+name, i18n.T(lang, "dashboard.msg_imported", name), false) +} + // maxPinsPerUser is a sanity cap, not a meaningful security boundary — pins // grant no access, they're just bookmarks — but an unbounded list would // still be an easy way to bloat one user's slice of the store.
cmd/gitfed-web/handlers_landing.go
diff --git a/cmd/gitfed-web/handlers_landing.go b/cmd/gitfed-web/handlers_landing.go index d8ce72f..0ede088 100644 --- a/cmd/gitfed-web/handlers_landing.go +++ b/cmd/gitfed-web/handlers_landing.go @@ -4,9 +4,7 @@ import ( "bytes" "html/template" "net/http" - "strings" - "gitfed" "gitfed/internal/i18n" ) @@ -70,11 +68,8 @@ var landingTpl = newTpl("landing", ` </div> </div> -<div class="changelog-preview"> - <div class="gf-page-head"><h2>{{t .Lang "landing.changelog_h"}}</h2><a class="see-all" href="/changelog">{{t .Lang "landing.changelog_link"}} {{icon "arrow"}}</a></div> - <div class="gf-card"> - <div class="gf-readme-body markdown-body">{{.ChangelogHTML}}</div> - </div> +<div class="changelog-cta"> + <a class="btn btn-secondary" href="/changelog">{{icon "history"}} {{t .Lang "landing.changelog_link"}}</a> </div> <div class="cta-band"> @@ -91,46 +86,14 @@ var landingTpl = newTpl("landing", ` </div> `) -// recentChangelogEntriesCount caps the landing page's "recent updates" -// preview — the full history stays one click away at /changelog. -const recentChangelogEntriesCount = 3 - -// recentChangelogEntries returns the Markdown source of the first n "## " -// version sections from CHANGELOG.md (it lists newest first), dropping the -// leading "# Changelog" title. -func recentChangelogEntries(n int) string { - var out []string - sections := 0 - for _, l := range strings.Split(gitfed.Changelog, "\n") { - if strings.HasPrefix(l, "## ") { - sections++ - if sections > n { - break - } - } - if sections == 0 { - continue - } - out = append(out, l) - } - return strings.Join(out, "\n") -} - func (s *server) handleLanding(w http.ResponseWriter, r *http.Request) { lang := s.lang(r) _, loggedIn := s.currentSession(r) - changelogHTML, err := renderMarkdown(recentChangelogEntries(recentChangelogEntriesCount)) - if err != nil { - s.serverError(w, r, err) - return - } - var buf bytes.Buffer _ = landingTpl.Execute(&buf, struct { - Lang string - LoggedIn bool - ChangelogHTML template.HTML - }{string(lang), loggedIn, changelogHTML}) + Lang string + LoggedIn bool + }{string(lang), loggedIn}) s.render(w, r, i18n.T(lang, "landing.title"), "home", template.HTML(buf.String())) }
cmd/gitfed-web/render.go
diff --git a/cmd/gitfed-web/render.go b/cmd/gitfed-web/render.go index eb8b2e2..cd00783 100644 --- a/cmd/gitfed-web/render.go +++ b/cmd/gitfed-web/render.go @@ -299,9 +299,7 @@ const shellHeadSrc = `<!doctype html> .fed-diagram { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 1.4rem; } .fed-diagram svg { width: 100%; height: auto; display: block; } - .changelog-preview { margin: 2.8rem 0; } - .changelog-preview .gf-page-head { margin-bottom: 1rem; } - .changelog-preview .see-all { color: var(--accent); text-decoration: none; font-size: 0.88rem; font-weight: 600; display: inline-flex; align-items: center; gap: 0.35rem; flex-shrink: 0; } + .changelog-cta { text-align: center; margin: 2.2rem 0; } .cta-band { text-align: center; padding: 2.4rem 1.4rem; margin: 2.6rem 0 1rem; background: var(--surface); border: 1px solid var(--border); border-radius: 14px; } .cta-band h2 { font-size: 1.25rem; margin: 0 0 0.5rem; } @@ -377,6 +375,7 @@ const shellHeadSrc = `<!doctype html> .gf-page-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem; } .gf-page-head h1, .gf-page-head h2 { font-size: 1.35rem; margin: 0; } .gf-page-head .count { font-family: var(--mono); font-size: 0.82rem; color: var(--text-faint); } + .gf-page-head-actions { display: flex; align-items: flex-start; gap: 0.5rem; } .gf-stat-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; margin-bottom: 1.25rem; } .gf-stat { background: var(--surface); padding: 1rem 1.1rem; }
cmd/gitfed-web/routes.go
diff --git a/cmd/gitfed-web/routes.go b/cmd/gitfed-web/routes.go index c79b3d3..84344c3 100644 --- a/cmd/gitfed-web/routes.go +++ b/cmd/gitfed-web/routes.go @@ -35,6 +35,7 @@ func (s *server) routes(mux *http.ServeMux) { // last segment of a pattern, so "/r/{repo...}/settings" isn't legal. mux.HandleFunc("GET /dashboard", s.requireLogin(s.handleDashboard)) mux.HandleFunc("POST /repos", s.requireLogin(s.handleCreateRepo)) + mux.HandleFunc("POST /repos/import", s.requireLogin(s.handleImportRepo)) mux.HandleFunc("GET /repo-settings/{repo...}", s.requireLogin(s.handleRepoSettingsForm)) mux.HandleFunc("POST /repo-settings/{repo...}", s.requireLogin(s.handleRepoSettingsSave)) mux.HandleFunc("POST /repo-grant/{repo...}", s.requireLogin(s.handleCollabGrant))
internal/admin/admin.go
diff --git a/internal/admin/admin.go b/internal/admin/admin.go index 9fd93a0..d6d577b 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -4,8 +4,11 @@ package admin import ( + "context" "fmt" "log" + "net/url" + "os" "strings" "time" @@ -45,6 +48,7 @@ type Ops interface { ListRepos() ([]store.Repo, error) GetRepo(name string) (store.Repo, error) CreateRepo(name, ownerUsername string) error + ImportRepo(name, sourceURL, ownerUsername string) error DeleteRepo(name string) error SetRepoPublic(name string, public bool) error SetRepoTopics(name string, topics []string) error @@ -272,6 +276,66 @@ func (a *Admin) CreateRepo(name, ownerUsername string) error { return a.Store.CreateRepo(store.Repo{Name: name, Owner: owner, Path: path}) } +// importCloneTimeout bounds how long a one-shot import's git clone may run, +// so a slow or hostile source can't tie up the server indefinitely. +const importCloneTimeout = 5 * time.Minute + +// ImportRepo one-shot-clones sourceURL into a new bare repo, registered like +// any other repo and owned by "<ownerUsername>@<localDomain>" — see +// ROADMAP.md §3 ("import ponctuel depuis un dépôt externe"). +func (a *Admin) ImportRepo(name, sourceURL, ownerUsername string) error { + if err := validateImportURL(sourceURL); err != nil { + return err + } + path, err := gitexec.ResolvePath(a.ReposDir, name) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), importCloneTimeout) + defer cancel() + if err := gitexec.CloneMirror(ctx, path, sourceURL); err != nil { + return err + } + + owner := fmt.Sprintf("%s@%s", ownerUsername, a.Domain) + if err := a.Store.CreateRepo(store.Repo{Name: name, Owner: owner, Path: path}); err != nil { + _ = os.RemoveAll(path) // don't leave an orphaned clone on disk if the store write fails + return err + } + return nil +} + +// validateImportURL restricts one-shot repo import to plain HTTPS URLs with +// no embedded credentials, resolving to a public address — the same +// SSRF/credential-leak concerns as federation discovery (see +// federation.CheckPublicHost), plus a hard scheme allowlist that rules out +// git's other transport helpers (ext::, fd::, file://, bare local paths), +// which can run arbitrary commands or read arbitrary server files if ever +// reached with a user-controlled URL. +func validateImportURL(raw string) error { + u, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("admin: invalid source URL: %w", err) + } + if u.Scheme != "https" { + return fmt.Errorf("admin: source URL must start with https://") + } + if u.User != nil { + return fmt.Errorf("admin: source URL must not carry credentials") + } + if u.Hostname() == "" { + return fmt.Errorf("admin: source URL must have a host") + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := federation.CheckPublicHost(ctx, u.Hostname()); err != nil { + return fmt.Errorf("admin: source URL host: %w", err) + } + return nil +} + func (a *Admin) DeleteRepo(name string) error { if err := a.Store.DeleteRepo(name); err != nil { return err
internal/adminrpc/client.go
diff --git a/internal/adminrpc/client.go b/internal/adminrpc/client.go index 636e756..d370259 100644 --- a/internal/adminrpc/client.go +++ b/internal/adminrpc/client.go @@ -108,6 +108,10 @@ func (c *Client) CreateRepo(name, ownerUsername string) error { return err } +func (c *Client) ImportRepo(name, sourceURL, ownerUsername string) error { + return c.call(methodImportRepo, importRepoArgs{Name: name, SourceURL: sourceURL, Owner: ownerUsername}, nil) +} + func (c *Client) DeleteRepo(name string) error { err := c.call(methodDeleteRepo, nameArgs{Name: name}, nil) return err
internal/adminrpc/protocol.go
diff --git a/internal/adminrpc/protocol.go b/internal/adminrpc/protocol.go index baf3fa3..63eeda6 100644 --- a/internal/adminrpc/protocol.go +++ b/internal/adminrpc/protocol.go @@ -22,6 +22,7 @@ const ( methodListRepos = "ListRepos" methodGetRepo = "GetRepo" methodCreateRepo = "CreateRepo" + methodImportRepo = "ImportRepo" methodDeleteRepo = "DeleteRepo" methodGetACL = "GetACL" methodGrantCollaborator = "GrantCollaborator" @@ -96,6 +97,12 @@ type createRepoArgs struct { Owner string `json:"owner"` } +type importRepoArgs struct { + Name string `json:"name"` + SourceURL string `json:"source_url"` + Owner string `json:"owner"` +} + type collaboratorArgs struct { Repo string `json:"repo"` Principal string `json:"principal"`
internal/adminrpc/server.go
diff --git a/internal/adminrpc/server.go b/internal/adminrpc/server.go index 0d7a274..6397b5e 100644 --- a/internal/adminrpc/server.go +++ b/internal/adminrpc/server.go @@ -116,6 +116,13 @@ func (s *Server) dispatch(req wireRequest) (any, error) { } return nil, s.ops.CreateRepo(a.Name, a.Owner) + case methodImportRepo: + var a importRepoArgs + if err := json.Unmarshal(req.Args, &a); err != nil { + return nil, err + } + return nil, s.ops.ImportRepo(a.Name, a.SourceURL, a.Owner) + case methodDeleteRepo: var a nameArgs if err := json.Unmarshal(req.Args, &a); err != nil {
internal/federation/wellknown.go
diff --git a/internal/federation/wellknown.go b/internal/federation/wellknown.go index f1e17e1..5357782 100644 --- a/internal/federation/wellknown.go +++ b/internal/federation/wellknown.go @@ -89,6 +89,31 @@ func ValidatePublicDomain(domain string) error { return nil } +// CheckPublicHost validates that host is safe to make an outbound +// connection to, for callers other than federation discovery that also dial +// a user-supplied hostname (currently: one-shot repo import, see +// ROADMAP.md §3): a syntactically valid public hostname (ValidatePublicDomain) +// that currently resolves only to public addresses. Unlike guardedDial, this +// can't pin the resolved IP for the caller's own connection — the caller +// (e.g. a `git clone` subprocess) does its own DNS resolution afterwards, so +// this is a best-effort pre-flight gate against the DNS-rebinding window, +// not a hard guarantee the way guardedDial is for the federation HTTP client. +func CheckPublicHost(ctx context.Context, host string) error { + if err := ValidatePublicDomain(host); err != nil { + return err + } + ips, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return fmt.Errorf("resolve %q: %w", host, err) + } + for _, ip := range ips { + if isDisallowedIP(ip.IP) { + return fmt.Errorf("refusing to connect to non-public address %s (for %s)", ip.IP, host) + } + } + return nil +} + // guardedDial resolves the target host, refuses any non-public address, then // dials the validated IP directly. func guardedDial(ctx context.Context, network, addr string) (net.Conn, error) {
internal/gitexec/gitexec.go
diff --git a/internal/gitexec/gitexec.go b/internal/gitexec/gitexec.go index 308828f..2de1a65 100644 --- a/internal/gitexec/gitexec.go +++ b/internal/gitexec/gitexec.go @@ -5,6 +5,7 @@ package gitexec import ( "bytes" + "context" "fmt" "io" "os" @@ -97,6 +98,38 @@ func InitBareRepo(path string) error { return nil } +// CloneMirror clones the repository at sourceURL into a new bare repo at +// path (one-shot import — see ROADMAP.md §3, "distinguished from continuous +// mirroring"), then drops the resulting "origin" remote so the bare repo +// never tries to re-contact the source on its own; nothing here ever fetches +// from it again. The caller must validate sourceURL first (scheme, host) — +// this only adds --end-of-options so a hostile URL starting with "-" can't +// be reinterpreted as a git flag, the same defense used everywhere else a +// user-controlled string reaches a git subprocess. +func CloneMirror(ctx context.Context, path, sourceURL string) error { + if _, err := os.Stat(path); err == nil { + return fmt.Errorf("gitexec: repo already exists at %s", path) + } + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + + cmd := exec.CommandContext(ctx, "git", "clone", "--mirror", "--quiet", "--end-of-options", sourceURL, path) + // Never prompt for credentials — an auth-required source must fail + // fast, not hang the request until the context times out. + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=") + if out, err := cmd.CombinedOutput(); err != nil { + _ = os.RemoveAll(path) + return fmt.Errorf("gitexec: git clone --mirror: %w: %s", err, out) + } + + if out, err := exec.Command("git", "--git-dir="+path, "remote", "remove", "origin").CombinedOutput(); err != nil { + _ = os.RemoveAll(path) + return fmt.Errorf("gitexec: detach origin remote: %w: %s", err, out) + } + return nil +} + // Serve runs the requested git service against path, wiring stdin/stdout to // the SSH channel and stderr to the given writer. func Serve(verb Verb, path string, stdin io.Reader, stdout, stderr io.Writer) error {
internal/i18n/strings_en.go
diff --git a/internal/i18n/strings_en.go b/internal/i18n/strings_en.go index 4c3faeb..b7055f4 100644 --- a/internal/i18n/strings_en.go +++ b/internal/i18n/strings_en.go @@ -62,7 +62,6 @@ var en = map[string]string{ "landing.cta_band_h": "Ready to take back control of your code?", "landing.cta_band_p": "Gitfed is open-source and self-hosted — one Go binary, one embedded database, no external dependencies.", "landing.cta_band_dashboard": "Go to your repos", - "landing.changelog_h": "Recent updates", "landing.changelog_link": "Full changelog", // ---------- security ---------- @@ -130,18 +129,23 @@ var en = map[string]string{ "role.admin": "admin", // ---------- dashboard ---------- - "dashboard.title": "Your repos", - "dashboard.stat_repos": "Repos", - "dashboard.stat_public": "Public", - "dashboard.stat_shared": "Shared with you", - "dashboard.new_repo": "New repo", - "dashboard.repo_name": "Name", - "dashboard.create": "Create", - "dashboard.empty": "No repos yet — create one above.", - "dashboard.msg_created": "created %s", - "dashboard.msg_name_required": "repository name is required", - "dashboard.msg_bad_namespace": "you can only create repositories in your own namespace", - "dashboard.msg_quota": "you have reached the maximum number of repositories", + "dashboard.title": "Your repos", + "dashboard.stat_repos": "Repos", + "dashboard.stat_public": "Public", + "dashboard.stat_shared": "Shared with you", + "dashboard.new_repo": "New repo", + "dashboard.repo_name": "Name", + "dashboard.create": "Create", + "dashboard.empty": "No repos yet — create one above.", + "dashboard.msg_created": "created %s", + "dashboard.msg_name_required": "repository name is required", + "dashboard.msg_bad_namespace": "you can only create repositories in your own namespace", + "dashboard.msg_quota": "you have reached the maximum number of repositories", + "dashboard.import_repo": "Import from URL", + "dashboard.import_url_label": "Source repository URL (HTTPS)", + "dashboard.import": "Import", + "dashboard.msg_import_url_required": "source repository URL is required", + "dashboard.msg_imported": "imported %s", // ---------- pinned repos ---------- "dashboard.pinned_title": "Pinned elsewhere",
internal/i18n/strings_fr.go
diff --git a/internal/i18n/strings_fr.go b/internal/i18n/strings_fr.go index 6c1256c..b5c36ef 100644 --- a/internal/i18n/strings_fr.go +++ b/internal/i18n/strings_fr.go @@ -62,7 +62,6 @@ var fr = map[string]string{ "landing.cta_band_h": "Envie de reprendre la main sur votre code ?", "landing.cta_band_p": "Gitfed est open-source et auto-hébergé — un binaire Go, une base embarquée, aucune dépendance externe.", "landing.cta_band_dashboard": "Aller à mes dépôts", - "landing.changelog_h": "Dernières mises à jour", "landing.changelog_link": "Changelog complet", // ---------- security ---------- @@ -130,18 +129,23 @@ var fr = map[string]string{ "role.admin": "admin", // ---------- dashboard ---------- - "dashboard.title": "Vos dépôts", - "dashboard.stat_repos": "Dépôts", - "dashboard.stat_public": "Publics", - "dashboard.stat_shared": "Partagés avec vous", - "dashboard.new_repo": "Nouveau dépôt", - "dashboard.repo_name": "Nom", - "dashboard.create": "Créer", - "dashboard.empty": "Aucun dépôt pour le moment — créez-en un ci-dessus.", - "dashboard.msg_created": "%s créé", - "dashboard.msg_name_required": "le nom du dépôt est obligatoire", - "dashboard.msg_bad_namespace": "vous ne pouvez créer des dépôts que dans votre propre espace de noms", - "dashboard.msg_quota": "vous avez atteint le nombre maximum de dépôts", + "dashboard.title": "Vos dépôts", + "dashboard.stat_repos": "Dépôts", + "dashboard.stat_public": "Publics", + "dashboard.stat_shared": "Partagés avec vous", + "dashboard.new_repo": "Nouveau dépôt", + "dashboard.repo_name": "Nom", + "dashboard.create": "Créer", + "dashboard.empty": "Aucun dépôt pour le moment — créez-en un ci-dessus.", + "dashboard.msg_created": "%s créé", + "dashboard.msg_name_required": "le nom du dépôt est obligatoire", + "dashboard.msg_bad_namespace": "vous ne pouvez créer des dépôts que dans votre propre espace de noms", + "dashboard.msg_quota": "vous avez atteint le nombre maximum de dépôts", + "dashboard.import_repo": "Importer depuis une URL", + "dashboard.import_url_label": "URL du dépôt source (HTTPS)", + "dashboard.import": "Importer", + "dashboard.msg_import_url_required": "l'URL du dépôt source est obligatoire", + "dashboard.msg_imported": "%s importé", // ---------- dépôts épinglés ---------- "dashboard.pinned_title": "Épinglés ailleurs",