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",