cmd/gitfed-renew-cert/main.go
diff --git a/cmd/gitfed-renew-cert/main.go b/cmd/gitfed-renew-cert/main.go
index 9101803..35c75f8 100644
--- a/cmd/gitfed-renew-cert/main.go
+++ b/cmd/gitfed-renew-cert/main.go
@@ -20,13 +20,20 @@ func main() {
host := flag.String("host", "", "home instance SSH address, e.g. instancea.example:22")
keyPath := flag.String("key", "", "path to the private key to authenticate with")
certPath := flag.String("cert", "", "path to read/write the certificate (default: <key>-cert.pub)")
- hostKey := flag.String("host-key", "", "expected server host key (authorized_keys format); if empty, the host key is NOT verified")
+ hostKey := flag.String("host-key", "", "expected server host key (authorized_keys format); required unless -insecure is set")
+ insecure := flag.Bool("insecure", false, "skip host-key verification (UNSAFE: allows a man-in-the-middle to intercept renewal)")
minTTL := flag.Duration("min-ttl", 6*time.Hour, "renew if less than this much validity remains")
force := flag.Bool("force", false, "renew even if the current certificate is still comfortably valid")
flag.Parse()
if *host == "" || *keyPath == "" {
- fmt.Fprintln(os.Stderr, "usage: gitfed-renew-cert -host <instance:port> -key <private-key-path> [-cert <path>] [-host-key <authorized_keys line>] [-min-ttl 6h]")
+ fmt.Fprintln(os.Stderr, "usage: gitfed-renew-cert -host <instance:port> -key <private-key-path> -host-key <authorized_keys line> [-cert <path>] [-min-ttl 6h]")
+ os.Exit(2)
+ }
+ // Fail closed: without a pinned host key we can't tell the real instance
+ // from a MITM. Verification must be explicitly waived with -insecure.
+ if *hostKey == "" && !*insecure {
+ fmt.Fprintln(os.Stderr, "gitfed-renew-cert: -host-key is required (or pass -insecure to skip verification, which is unsafe)")
os.Exit(2)
}
if *certPath == "" {
@@ -45,7 +52,7 @@ func main() {
}
}
- if err := renew(*host, *keyPath, *certPath, *hostKey); err != nil {
+ if err := renew(*host, *keyPath, *certPath, *hostKey, *insecure); err != nil {
fmt.Fprintln(os.Stderr, "gitfed-renew-cert:", err)
os.Exit(1)
}
@@ -71,7 +78,7 @@ func certRemainingTTL(path string) (time.Duration, bool) {
return time.Until(validBefore), true
}
-func renew(host, keyPath, certPath, hostKeyAuthorized string) error {
+func renew(host, keyPath, certPath, hostKeyAuthorized string, insecure bool) error {
keyData, err := os.ReadFile(keyPath)
if err != nil {
return fmt.Errorf("read private key: %w", err)
@@ -81,15 +88,18 @@ func renew(host, keyPath, certPath, hostKeyAuthorized string) error {
return fmt.Errorf("parse private key: %w", err)
}
- hostKeyCallback := gossh.InsecureIgnoreHostKey()
+ var hostKeyCallback gossh.HostKeyCallback
if hostKeyAuthorized != "" {
expected, _, _, _, err := gossh.ParseAuthorizedKey([]byte(hostKeyAuthorized))
if err != nil {
return fmt.Errorf("parse -host-key: %w", err)
}
hostKeyCallback = gossh.FixedHostKey(expected)
+ } else if insecure {
+ fmt.Fprintln(os.Stderr, "warning: -insecure set, the server's host key will NOT be verified")
+ hostKeyCallback = gossh.InsecureIgnoreHostKey()
} else {
- fmt.Fprintln(os.Stderr, "warning: -host-key not set, the server's host key will not be verified")
+ return fmt.Errorf("no host key to verify against (this should have been caught earlier)")
}
client, err := gossh.Dial("tcp", host, &gossh.ClientConfig{
cmd/gitfed-server/main.go
diff --git a/cmd/gitfed-server/main.go b/cmd/gitfed-server/main.go
index 19df0ac..d16a1fe 100644
--- a/cmd/gitfed-server/main.go
+++ b/cmd/gitfed-server/main.go
@@ -9,6 +9,7 @@ import (
"log"
"net/http"
"os"
+ "time"
"gitfed/internal/admin"
"gitfed/internal/adminrpc"
@@ -108,6 +109,18 @@ func run(configPath string) error {
}
}
+ // Sweep expired web sessions periodically — GetSession only evicts them
+ // lazily on access, so ones never looked up again would otherwise linger.
+ go func() {
+ for range time.Tick(time.Hour) {
+ if n, err := st.PurgeExpiredSessions(); err != nil {
+ log.Printf("session purge: %v", err)
+ } else if n > 0 {
+ log.Printf("purged %d expired session(s)", n)
+ }
+ }
+ }()
+
resolver := federation.NewResolver(st, cfg.Domain, cfg.InsecureFederation)
adminOps := admin.New(st, resolver, cfg.Domain, cfg.ReposDir)
@@ -123,8 +136,16 @@ func run(configPath string) error {
handler := federation.Handler(cfg.Domain, cfg.Contact, version.Version, localCA)
mux := http.NewServeMux()
mux.Handle("/.well-known/gitfed.json", handler)
+ srv := &http.Server{
+ Addr: cfg.ListenHTTP,
+ Handler: mux,
+ ReadHeaderTimeout: 5 * time.Second,
+ ReadTimeout: 10 * time.Second,
+ WriteTimeout: 10 * time.Second,
+ IdleTimeout: 30 * time.Second,
+ }
log.Printf("gitfed well-known endpoint listening on %s", cfg.ListenHTTP)
- if err := http.ListenAndServe(cfg.ListenHTTP, mux); err != nil {
+ if err := srv.ListenAndServe(); err != nil {
log.Printf("well-known http server: %v", err)
}
}()
cmd/gitfed-web/handlers_admin.go
diff --git a/cmd/gitfed-web/handlers_admin.go b/cmd/gitfed-web/handlers_admin.go
index 5bb3611..aa8d25f 100644
--- a/cmd/gitfed-web/handlers_admin.go
+++ b/cmd/gitfed-web/handlers_admin.go
@@ -5,54 +5,55 @@ import (
"html/template"
"net/http"
+ "gitfed/internal/i18n"
"gitfed/internal/store"
)
-var adminIndexTpl = template.Must(template.New("admin-index").Parse(`
-<div class="gf-page-head"><h1>Admin</h1></div>
+var adminIndexTpl = newTpl("admin-index", `
+<div class="gf-page-head"><h1>{{t .Lang "admin.title"}}</h1></div>
<div class="gf-admin-grid">
<div class="gf-admin-card">
- <div class="top"><span class="l">Users</span></div>
+ <div class="top"><span class="l">{{t .Lang "admin.card_users"}}</span></div>
<div class="n">{{.UserCount}}</div>
- <p>{{.AdminCount}} admin{{if ne .AdminCount 1}}s{{end}}, {{.RegularCount}} regular</p>
- <a class="go" href="/admin/users">Manage users →</a>
+ <p>{{t .Lang "admin.card_users_sub" .AdminCount .RegularCount}}</p>
+ <a class="go" href="/admin/users">{{t .Lang "admin.card_users_link"}} →</a>
</div>
<div class="gf-admin-card">
<div class="top">
- <span class="l">Trust store</span>
- {{if .PendingCount}}<span class="badge pending">{{.PendingCount}} pending</span>{{end}}
+ <span class="l">{{t .Lang "admin.card_trust"}}</span>
+ {{if .PendingCount}}<span class="badge pending">{{.PendingCount}} {{t .Lang "admin.card_trust_pending"}}</span>{{end}}
</div>
<div class="n">{{.TrustCount}}</div>
- <p>federated domains known</p>
- <a class="go" href="/admin/trust">Review trust store →</a>
+ <p>{{t .Lang "admin.card_trust_sub"}}</p>
+ <a class="go" href="/admin/trust">{{t .Lang "admin.card_trust_link"}} →</a>
</div>
<div class="gf-admin-card">
- <div class="top"><span class="l">Audit log</span></div>
+ <div class="top"><span class="l">{{t .Lang "admin.card_audit"}}</span></div>
<div class="n">{{.AuditCount}}</div>
- <p>most recent events on record</p>
- <a class="go" href="/admin/audit">View audit log →</a>
+ <p>{{t .Lang "admin.card_audit_sub"}}</p>
+ <a class="go" href="/admin/audit">{{t .Lang "admin.card_audit_link"}} →</a>
</div>
</div>
{{if .RecentAudit}}
-<h3 style="font-size:0.95rem; margin-bottom:0.6rem;">Recent activity</h3>
+<h3 style="font-size:0.95rem; margin-bottom:0.6rem;">{{t .Lang "admin.recent_activity"}}</h3>
<div class="gf-card gf-audit-mini">
{{range .RecentAudit}}
<div class="gf-audit-row">
<span class="t">{{.Time.Local.Format "15:04:05"}}</span>
<span class="who">{{.Principal}} · {{.Action}}{{if .Repo}} · {{.Repo}}{{end}}</span>
- {{if .Allowed}}<span class="badge trusted">allow</span>{{else}}<span class="badge pending">deny</span>{{end}}
+ {{if .Allowed}}<span class="badge trusted">{{t $.Lang "admin.audit_allow"}}</span>{{else}}<span class="badge pending">{{t $.Lang "admin.audit_deny"}}</span>{{end}}
</div>
{{end}}
</div>
{{end}}
-`))
+`)
func (s *server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
users, err := s.ops.ListUsers()
if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ s.serverError(w, r, err)
return
}
adminCount := 0
@@ -64,7 +65,7 @@ func (s *server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
trust, err := s.ops.ListTrustedCAs()
if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ s.serverError(w, r, err)
return
}
pendingCount := 0
@@ -76,7 +77,7 @@ func (s *server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
audit, err := s.ops.ListAudit(200)
if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ s.serverError(w, r, err)
return
}
recent := audit
@@ -84,12 +85,14 @@ func (s *server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
recent = recent[:5]
}
+ lang := s.lang(r)
var buf bytes.Buffer
_ = adminIndexTpl.Execute(&buf, struct {
UserCount, AdminCount, RegularCount int
TrustCount, PendingCount int
AuditCount int
+ Lang string
RecentAudit []store.AuditEvent
- }{len(users), adminCount, len(users) - adminCount, len(trust), pendingCount, len(audit), recent})
- s.render(w, r, "Admin", "admin", template.HTML(buf.String()))
+ }{len(users), adminCount, len(users) - adminCount, len(trust), pendingCount, len(audit), string(lang), recent})
+ s.render(w, r, i18n.T(lang, "admin.title"), "admin", template.HTML(buf.String()))
}
cmd/gitfed-web/handlers_admin_audit.go
diff --git a/cmd/gitfed-web/handlers_admin_audit.go b/cmd/gitfed-web/handlers_admin_audit.go
index 5ee80b4..641f26c 100644
--- a/cmd/gitfed-web/handlers_admin_audit.go
+++ b/cmd/gitfed-web/handlers_admin_audit.go
@@ -5,39 +5,42 @@ import (
"html/template"
"net/http"
+ "gitfed/internal/i18n"
"gitfed/internal/store"
)
-var adminAuditTpl = template.Must(template.New("admin-audit").Parse(`
-<p><a href="/admin">← Admin</a></p>
-<h1>Audit log</h1>
+var adminAuditTpl = newTpl("admin-audit", `
+<p><a href="/admin">← {{t .Lang "nav.admin"}}</a></p>
+<h1>{{t .Lang "admin.audit_title"}}</h1>
<table>
-<tr><th>Time</th><th>Action</th><th>Principal</th><th>Repo/Domain</th><th>Result</th><th>Detail</th></tr>
+<tr><th>{{t .Lang "admin.audit_col_time"}}</th><th>{{t .Lang "admin.audit_col_action"}}</th><th>{{t .Lang "admin.audit_col_principal"}}</th><th>{{t .Lang "admin.audit_col_repo_domain"}}</th><th>{{t .Lang "admin.audit_col_result"}}</th><th>{{t .Lang "admin.audit_col_detail"}}</th></tr>
{{range .Events}}
<tr>
<td class="muted">{{.Time.Local.Format "2006-01-02 15:04:05"}}</td>
<td>{{.Action}}</td>
<td>{{.Principal}}</td>
<td>{{if .Repo}}{{.Repo}}{{else}}{{.Domain}}{{end}}</td>
- <td>{{if .Allowed}}<span class="badge trusted">allow</span>{{else}}<span class="badge pending">deny</span>{{end}}</td>
+ <td>{{if .Allowed}}<span class="badge trusted">{{t $.Lang "admin.audit_allow"}}</span>{{else}}<span class="badge pending">{{t $.Lang "admin.audit_deny"}}</span>{{end}}</td>
<td class="muted">{{.Detail}}</td>
</tr>
{{else}}
-<tr><td colspan="6" class="muted">No events recorded yet.</td></tr>
+<tr><td colspan="6" class="muted">{{t .Lang "admin.audit_empty"}}</td></tr>
{{end}}
</table>
-<p class="muted">Showing the most recent 200 events.</p>
-`))
+<p class="muted">{{t .Lang "admin.audit_note"}}</p>
+`)
func (s *server) handleAdminAudit(w http.ResponseWriter, r *http.Request) {
+ lang := s.lang(r)
events, err := s.ops.ListAudit(200)
if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ s.serverError(w, r, err)
return
}
var buf bytes.Buffer
_ = adminAuditTpl.Execute(&buf, struct {
Events []store.AuditEvent
- }{events})
- s.render(w, r, "Admin — Audit log", "admin", template.HTML(buf.String()))
+ Lang string
+ }{events, string(lang)})
+ s.render(w, r, i18n.T(lang, "admin.audit_title"), "admin", template.HTML(buf.String()))
}
cmd/gitfed-web/handlers_admin_trust.go
diff --git a/cmd/gitfed-web/handlers_admin_trust.go b/cmd/gitfed-web/handlers_admin_trust.go
index 67ba426..6de956d 100644
--- a/cmd/gitfed-web/handlers_admin_trust.go
+++ b/cmd/gitfed-web/handlers_admin_trust.go
@@ -5,55 +5,59 @@ import (
"html/template"
"net/http"
+ "gitfed/internal/i18n"
"gitfed/internal/store"
)
-var adminTrustTpl = template.Must(template.New("admin-trust").Parse(`
+var adminTrustTpl = newTpl("admin-trust", `
{{.Flash}}
-<p><a href="/admin">← Admin</a></p>
-<h1>Trust store</h1>
+<p><a href="/admin">← {{t .Lang "nav.admin"}}</a></p>
+<h1>{{t .Lang "admin.trust_title"}}</h1>
<table>
-<tr><th>Domain</th><th>Status</th><th>First seen</th><th></th></tr>
+<tr><th>{{t .Lang "admin.trust_col_domain"}}</th><th>{{t .Lang "admin.trust_col_status"}}</th><th>{{t .Lang "admin.trust_col_first_seen"}}</th><th></th></tr>
{{range .Trust}}
<tr>
<td>{{.Domain}}</td>
- <td><span class="badge {{.Status}}">{{.Status}}</span></td>
+ <td><span class="badge {{.Status}}">{{if eq (print .Status) "pending"}}{{t $.Lang "admin.trust_status_pending"}}{{else}}{{t $.Lang "admin.trust_status_trusted"}}{{end}}</span></td>
<td class="muted">{{.FirstSeenAt.Local.Format "2006-01-02 15:04"}}</td>
<td>
- {{if eq .Status "pending"}}
+ {{if eq (print .Status) "pending"}}
<form class="inline" method="post" action="/admin/trust/approve">
<input type="hidden" name="domain" value="{{.Domain}}">
- <button type="submit">Approve</button>
+ <button type="submit">{{t $.Lang "admin.approve"}}</button>
</form>
{{end}}
</td>
</tr>
{{else}}
-<tr><td colspan="4" class="muted">No remote domains discovered yet.</td></tr>
+<tr><td colspan="4" class="muted">{{t .Lang "admin.trust_empty"}}</td></tr>
{{end}}
</table>
-<p class="muted">Domains show up here automatically the first time a collaborator on a remote domain is granted access to a repo. Under the default whitelist policy they stay pending until approved here.</p>
-`))
+<p class="muted">{{t .Lang "admin.trust_note"}}</p>
+`)
func (s *server) handleAdminTrustList(w http.ResponseWriter, r *http.Request) {
+ lang := s.lang(r)
trust, err := s.ops.ListTrustedCAs()
if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ s.serverError(w, r, err)
return
}
var buf bytes.Buffer
_ = adminTrustTpl.Execute(&buf, struct {
Trust []store.TrustedCA
+ Lang string
Flash template.HTML
- }{trust, flash(r)})
- s.render(w, r, "Admin — Trust store", "admin", template.HTML(buf.String()))
+ }{trust, string(lang), flash(r)})
+ s.render(w, r, i18n.T(lang, "admin.trust_title"), "admin", template.HTML(buf.String()))
}
func (s *server) handleAdminTrustApprove(w http.ResponseWriter, r *http.Request) {
+ lang := s.lang(r)
domain := r.FormValue("domain")
if err := s.ops.ApproveDomain(domain); err != nil {
redirectWithMsg(w, r, "/admin/trust", err.Error(), true)
return
}
- redirectWithMsg(w, r, "/admin/trust", "approved "+domain, false)
+ redirectWithMsg(w, r, "/admin/trust", i18n.T(lang, "admin.msg_domain_approved", domain), false)
}
cmd/gitfed-web/handlers_admin_users.go
diff --git a/cmd/gitfed-web/handlers_admin_users.go b/cmd/gitfed-web/handlers_admin_users.go
index 683fc51..fb184ea 100644
--- a/cmd/gitfed-web/handlers_admin_users.go
+++ b/cmd/gitfed-web/handlers_admin_users.go
@@ -5,15 +5,16 @@ import (
"html/template"
"net/http"
+ "gitfed/internal/i18n"
"gitfed/internal/store"
)
-var adminUsersTpl = template.Must(template.New("admin-users").Parse(`
+var adminUsersTpl = newTpl("admin-users", `
{{.Flash}}
-<p><a href="/admin">← Admin</a></p>
-<h1>Users</h1>
+<p><a href="/admin">← {{t .Lang "nav.admin"}}</a></p>
+<h1>{{t .Lang "admin.users_title"}}</h1>
<table>
-<tr><th>Username</th><th>Keys</th><th>Admin</th><th></th></tr>
+<tr><th>{{t .Lang "admin.users_col_username"}}</th><th>{{t .Lang "admin.users_col_keys"}}</th><th>{{t .Lang "nav.admin"}}</th><th></th></tr>
{{range .Users}}
<tr>
<td>{{.Username}}</td>
@@ -22,49 +23,52 @@ var adminUsersTpl = template.Must(template.New("admin-users").Parse(`
<form class="inline" method="post" action="/admin/users/set-admin">
<input type="hidden" name="username" value="{{.Username}}">
<input type="hidden" name="is_admin" value="{{if .IsAdmin}}0{{else}}1{{end}}">
- <button type="submit">{{if .IsAdmin}}Revoke admin{{else}}Make admin{{end}}</button>
+ <button type="submit">{{if .IsAdmin}}{{t $.Lang "admin.revoke_admin"}}{{else}}{{t $.Lang "admin.make_admin"}}{{end}}</button>
</form>
</td>
<td>
- <form class="inline" method="post" action="/admin/users/delete" onsubmit="return confirm('Delete user {{.Username}}?');">
+ <form class="inline" method="post" action="/admin/users/delete" data-confirm="{{t $.Lang "admin.confirm_delete_user"}} {{.Username}}?">
<input type="hidden" name="username" value="{{.Username}}">
- <button class="danger" type="submit">Delete</button>
+ <button class="danger" type="submit">{{t $.Lang "settings.remove"}}</button>
</form>
</td>
</tr>
{{else}}
-<tr><td colspan="4" class="muted">No users yet.</td></tr>
+<tr><td colspan="4" class="muted">{{t .Lang "admin.users_empty"}}</td></tr>
{{end}}
</table>
<form class="card" method="post" action="/admin/users">
- <strong>Add user</strong>
- <label>Username</label>
+ <strong>{{t .Lang "admin.add_user"}}</strong>
+ <label>{{t .Lang "admin.users_col_username"}}</label>
<input name="username" required placeholder="alice">
- <label>Public key (authorized_keys format)</label>
+ <label>{{t .Lang "settings.pubkey_label"}}</label>
<input name="pubkey" required placeholder="ssh-ed25519 AAAA... comment">
- <label>Initial password</label>
+ <label>{{t .Lang "admin.initial_password"}}</label>
<input name="password" type="password" required minlength="8">
- <label><input type="checkbox" name="is_admin" value="1" style="width:auto; display:inline-block;"> Grant admin</label>
- <button type="submit">Create</button>
+ <label><input type="checkbox" name="is_admin" value="1" style="width:auto; display:inline-block;"> {{t .Lang "admin.grant_admin"}}</label>
+ <button type="submit">{{t .Lang "dashboard.create"}}</button>
</form>
-`))
+`)
func (s *server) handleAdminUsersList(w http.ResponseWriter, r *http.Request) {
+ lang := s.lang(r)
users, err := s.ops.ListUsers()
if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ s.serverError(w, r, err)
return
}
var buf bytes.Buffer
_ = adminUsersTpl.Execute(&buf, struct {
Users []store.User
+ Lang string
Flash template.HTML
- }{users, flash(r)})
- s.render(w, r, "Admin — Users", "admin", template.HTML(buf.String()))
+ }{users, string(lang), flash(r)})
+ s.render(w, r, i18n.T(lang, "admin.users_title"), "admin", template.HTML(buf.String()))
}
func (s *server) handleAdminUsersCreate(w http.ResponseWriter, r *http.Request) {
+ lang := s.lang(r)
username := r.FormValue("username")
pubkey := r.FormValue("pubkey")
password := r.FormValue("password")
@@ -83,24 +87,26 @@ func (s *server) handleAdminUsersCreate(w http.ResponseWriter, r *http.Request)
return
}
}
- redirectWithMsg(w, r, "/admin/users", "created user "+username, false)
+ redirectWithMsg(w, r, "/admin/users", i18n.T(lang, "admin.msg_user_created", username), false)
}
func (s *server) handleAdminUsersDelete(w http.ResponseWriter, r *http.Request) {
+ lang := s.lang(r)
username := r.FormValue("username")
if err := s.ops.DeleteUser(username); err != nil {
redirectWithMsg(w, r, "/admin/users", err.Error(), true)
return
}
- redirectWithMsg(w, r, "/admin/users", "deleted user "+username, false)
+ redirectWithMsg(w, r, "/admin/users", i18n.T(lang, "admin.msg_user_deleted", username), false)
}
func (s *server) handleAdminUsersSetAdmin(w http.ResponseWriter, r *http.Request) {
+ lang := s.lang(r)
username := r.FormValue("username")
isAdmin := r.FormValue("is_admin") == "1"
if err := s.ops.SetUserAdmin(username, isAdmin); err != nil {
redirectWithMsg(w, r, "/admin/users", err.Error(), true)
return
}
- redirectWithMsg(w, r, "/admin/users", "updated "+username, false)
+ redirectWithMsg(w, r, "/admin/users", i18n.T(lang, "admin.msg_user_updated", username), false)
}
cmd/gitfed-web/handlers_auth.go
diff --git a/cmd/gitfed-web/handlers_auth.go b/cmd/gitfed-web/handlers_auth.go
index d91d7c5..87d53ef 100644
--- a/cmd/gitfed-web/handlers_auth.go
+++ b/cmd/gitfed-web/handlers_auth.go
@@ -3,57 +3,80 @@ package main
import (
"bytes"
"html/template"
+ "log"
"net/http"
"strings"
+
+ "gitfed/internal/i18n"
)
-var loginTpl = template.Must(template.New("login").Parse(`
+var loginTpl = newTpl("login", `
{{.Flash}}
<form class="card" method="post" action="/login">
- <strong>Log in</strong>
+ <strong>{{t .Lang "auth.login"}}</strong>
<input type="hidden" name="next" value="{{.Next}}">
- <label>Username</label>
+ <label>{{t .Lang "auth.username"}}</label>
<input name="username" required autofocus>
- <label>Password</label>
+ <label>{{t .Lang "auth.password"}}</label>
<input name="password" type="password" required>
- <button type="submit">Log in</button>
+ <button type="submit">{{t .Lang "auth.login"}}</button>
</form>
-<p class="muted">Accounts are created by an instance admin — there's no self-registration. This only logs you into the web UI; git push/pull still goes over SSH with your key.</p>
-`))
+<p class="muted">{{t .Lang "auth.note"}}</p>
+`)
func (s *server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
if _, ok := s.currentSession(r); ok {
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
return
}
+ lang := s.lang(r)
next := sanitizeNext(r.URL.Query().Get("next"))
var buf bytes.Buffer
_ = loginTpl.Execute(&buf, struct {
- Next string
- Flash template.HTML
- }{next, flash(r)})
- s.render(w, r, "Log in", "login", template.HTML(buf.String()))
+ Next, Lang string
+ Flash template.HTML
+ }{next, string(lang), flash(r)})
+ s.render(w, r, i18n.T(lang, "auth.login"), "login", template.HTML(buf.String()))
}
func (s *server) handleLogin(w http.ResponseWriter, r *http.Request) {
username := r.FormValue("username")
password := r.FormValue("password")
+ lang := s.lang(r)
next := sanitizeNext(r.FormValue("next"))
+ loginURL := "/login?next=" + template.URLQueryEscaper(next)
+
+ // Refuse before touching bcrypt once either the source IP or the target
+ // account has accumulated too many recent failures.
+ ip := clientIP(r)
+ if !s.loginByIP.allowed(ip) || !s.loginByUser.allowed(username) {
+ redirectWithMsg(w, r, loginURL, i18n.T(lang, "auth.rate_limited"), true)
+ return
+ }
isAdmin, ok, err := s.ops.VerifyPassword(username, password)
if err != nil {
- redirectWithMsg(w, r, "/login?next="+template.URLQueryEscaper(next), err.Error(), true)
+ log.Printf("gitfed-web: verify password for %q: %v", username, err)
+ redirectWithMsg(w, r, loginURL, i18n.T(lang, "auth.error"), true)
return
}
if !ok {
- redirectWithMsg(w, r, "/login?next="+template.URLQueryEscaper(next), "invalid username or password", true)
+ s.loginByIP.recordFailure(ip)
+ s.loginByUser.recordFailure(username)
+ redirectWithMsg(w, r, loginURL, i18n.T(lang, "auth.invalid_login"), true)
return
}
+ // Proven identity — clear the counters so a later typo isn't penalised
+ // against an earlier attacker's tally.
+ s.loginByIP.reset(ip)
+ s.loginByUser.reset(username)
+
principal := username + "@" + s.domain
token, err := s.ops.CreateSession(principal, username, isAdmin)
if err != nil {
- redirectWithMsg(w, r, "/login?next="+template.URLQueryEscaper(next), err.Error(), true)
+ log.Printf("gitfed-web: create session for %q: %v", username, err)
+ redirectWithMsg(w, r, loginURL, i18n.T(lang, "auth.error"), true)
return
}
setSessionCookie(w, token)
cmd/gitfed-web/handlers_changelog.go
diff --git a/cmd/gitfed-web/handlers_changelog.go b/cmd/gitfed-web/handlers_changelog.go
index 81bb92d..56e6ffe 100644
--- a/cmd/gitfed-web/handlers_changelog.go
+++ b/cmd/gitfed-web/handlers_changelog.go
@@ -5,14 +5,15 @@ import (
"net/http"
"gitfed"
+ "gitfed/internal/i18n"
)
func (s *server) handleChangelog(w http.ResponseWriter, r *http.Request) {
rendered, err := renderMarkdown(gitfed.Changelog)
if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ s.serverError(w, r, err)
return
}
body := `<div class="markdown-body">` + string(rendered) + `</div>`
- s.render(w, r, "Changelog", "", template.HTML(body))
+ s.render(w, r, i18n.T(s.lang(r), "changelog.title"), "", template.HTML(body))
}
cmd/gitfed-web/handlers_dashboard.go
diff --git a/cmd/gitfed-web/handlers_dashboard.go b/cmd/gitfed-web/handlers_dashboard.go
index d3642eb..f668def 100644
--- a/cmd/gitfed-web/handlers_dashboard.go
+++ b/cmd/gitfed-web/handlers_dashboard.go
@@ -4,26 +4,28 @@ import (
"bytes"
"html/template"
"net/http"
+ "strings"
+ "gitfed/internal/i18n"
"gitfed/internal/store"
)
-var dashboardTpl = template.Must(template.New("dashboard").Parse(`
+var dashboardTpl = newTpl("dashboard", `
{{.Flash}}
<div class="gf-stat-row">
- <div class="gf-stat"><div class="n">{{len .Repos}}</div><div class="l">Repos</div></div>
- <div class="gf-stat"><div class="n">{{.PublicCount}}</div><div class="l">Public</div></div>
- <div class="gf-stat"><div class="n">{{.SharedCount}}</div><div class="l">Shared with you</div></div>
+ <div class="gf-stat"><div class="n">{{len .Repos}}</div><div class="l">{{t .Lang "dashboard.stat_repos"}}</div></div>
+ <div class="gf-stat"><div class="n">{{.PublicCount}}</div><div class="l">{{t .Lang "dashboard.stat_public"}}</div></div>
+ <div class="gf-stat"><div class="n">{{.SharedCount}}</div><div class="l">{{t .Lang "dashboard.stat_shared"}}</div></div>
</div>
<div class="gf-page-head">
- <h1>Your repos</h1>
+ <h1>{{t .Lang "dashboard.title"}}</h1>
<details class="gf-new-repo">
- <summary class="gf-btn primary" style="margin:0;">+ New repo</summary>
+ <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>Name</label>
+ <label>{{t .Lang "dashboard.repo_name"}}</label>
<input name="name" required placeholder="{{.Username}}/my-project">
- <button type="submit">Create</button>
+ <button type="submit">{{t .Lang "dashboard.create"}}</button>
</form>
</details>
</div>
@@ -31,18 +33,18 @@ var dashboardTpl = template.Must(template.New("dashboard").Parse(`
<div class="gf-card gf-repo-list">
{{range .Repos}}
<div class="gf-repo-row">
- <div class="gf-repo-icon">📁</div>
+ <div class="gf-repo-icon">{{icon "folder"}}</div>
<div class="gf-repo-main">
<div class="name"><a href="/r/{{.Name}}">{{.Name}}</a></div>
- <div class="meta">{{.Role}}{{if .Topics}} · {{end}}{{range $i, $t := .Topics}}{{if $i}}, {{end}}{{$t}}{{end}}</div>
+ <div class="meta">{{roleLabel $.Lang .Role}}{{if .Topics}} · {{end}}{{range $i, $t := .Topics}}{{if $i}}, {{end}}{{$t}}{{end}}</div>
</div>
- {{if .Public}}<span class="badge trusted">public</span>{{else}}<span class="badge pending">private</span>{{end}}
+ {{if .Public}}<span class="badge trusted">{{t $.Lang "common.public"}}</span>{{else}}<span class="badge pending">{{t $.Lang "common.private"}}</span>{{end}}
</div>
{{else}}
- <div class="gf-repo-row"><span class="muted">No repos yet — create one above.</span></div>
+ <div class="gf-repo-row"><span class="muted">{{t .Lang "dashboard.empty"}}</span></div>
{{end}}
</div>
-`))
+`)
type dashboardRepo struct {
store.Repo
@@ -51,10 +53,11 @@ type dashboardRepo struct {
func (s *server) handleDashboard(w http.ResponseWriter, r *http.Request) {
sess, _ := s.currentSession(r)
+ lang := s.lang(r)
all, err := s.ops.ListRepos()
if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ s.serverError(w, r, err)
return
}
var mine []dashboardRepo
@@ -76,19 +79,57 @@ func (s *server) handleDashboard(w http.ResponseWriter, r *http.Request) {
var buf bytes.Buffer
_ = dashboardTpl.Execute(&buf, struct {
Repos []dashboardRepo
- Username string
+ Username, Lang string
PublicCount, SharedCount int
Flash template.HTML
- }{mine, sess.Username, publicCount, sharedCount, flash(r)})
- s.render(w, r, "Dashboard", "dashboard", template.HTML(buf.String()))
+ }{mine, sess.Username, string(lang), publicCount, sharedCount, flash(r)})
+ s.render(w, r, i18n.T(lang, "dashboard.title"), "dashboard", template.HTML(buf.String()))
}
+// maxReposPerUser caps how many repos one account may own, so a single
+// 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)
- name := r.FormValue("name")
+ lang := s.lang(r)
+ name := strings.Trim(strings.TrimSpace(r.FormValue("name")), "/")
+
+ // 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
+ }
+ if strings.Contains(name, "/") {
+ if !strings.HasPrefix(name, sess.Username+"/") {
+ redirectWithMsg(w, r, "/dashboard", i18n.T(lang, "dashboard.msg_bad_namespace"), true)
+ return
+ }
+ } 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
+ }
+ }
+
if err := s.ops.CreateRepo(name, sess.Username); err != nil {
redirectWithMsg(w, r, "/dashboard", err.Error(), true)
return
}
- redirectWithMsg(w, r, "/repo-settings/"+name, "created "+name, false)
+ redirectWithMsg(w, r, "/repo-settings/"+name, i18n.T(lang, "dashboard.msg_created", name), false)
}
cmd/gitfed-web/handlers_home.go
diff --git a/cmd/gitfed-web/handlers_home.go b/cmd/gitfed-web/handlers_home.go
index cc8e071..fd8c2f0 100644
--- a/cmd/gitfed-web/handlers_home.go
+++ b/cmd/gitfed-web/handlers_home.go
@@ -5,33 +5,35 @@ import (
"html/template"
"net/http"
+ "gitfed/internal/i18n"
"gitfed/internal/store"
)
-var homeTpl = template.Must(template.New("home").Parse(`
+var exploreTpl = newTpl("explore", `
{{.Flash}}
-<h1>Explore public repositories</h1>
-{{if .Topic}}<p class="muted">Filtered by topic <span class="badge plain">{{.Topic}}</span> — <a href="/">clear</a></p>{{end}}
+<h1>{{t .Lang "explore.title"}}</h1>
+{{if .Topic}}<p class="muted">{{t .Lang "explore.filtered_by"}} <span class="badge plain">{{.Topic}}</span> — <a href="/explore">{{t .Lang "explore.clear"}}</a></p>{{end}}
<table>
-<tr><th>Repo</th><th>Owner</th><th>Topics</th></tr>
+<tr><th>{{t .Lang "explore.col_repo"}}</th><th>{{t .Lang "explore.col_owner"}}</th><th>{{t .Lang "explore.col_topics"}}</th></tr>
{{range .Repos}}
<tr>
<td><a href="/r/{{.Name}}">{{.Name}}</a></td>
<td class="muted">{{.Owner}}</td>
- <td>{{range .Topics}}<a class="badge plain" href="/?topic={{.}}">{{.}}</a>{{end}}</td>
+ <td>{{range .Topics}}<a class="badge plain" href="/explore?topic={{.}}">{{.}}</a>{{end}}</td>
</tr>
{{else}}
-<tr><td colspan="3" class="muted">No public repositories yet.</td></tr>
+<tr><td colspan="3" class="muted">{{t .Lang "explore.empty"}}</td></tr>
{{end}}
</table>
-`))
+`)
-func (s *server) handleHome(w http.ResponseWriter, r *http.Request) {
+func (s *server) handleExplore(w http.ResponseWriter, r *http.Request) {
topic := r.URL.Query().Get("topic")
+ lang := s.lang(r)
repos, err := s.ops.ListRepos()
if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ s.serverError(w, r, err)
return
}
var visible []store.Repo
@@ -46,12 +48,12 @@ func (s *server) handleHome(w http.ResponseWriter, r *http.Request) {
}
var buf bytes.Buffer
- _ = homeTpl.Execute(&buf, struct {
- Repos []store.Repo
- Topic string
- Flash template.HTML
- }{visible, topic, flash(r)})
- s.render(w, r, "Explore", "home", template.HTML(buf.String()))
+ _ = exploreTpl.Execute(&buf, struct {
+ Repos []store.Repo
+ Topic, Lang string
+ Flash template.HTML
+ }{visible, topic, string(lang), flash(r)})
+ s.render(w, r, i18n.T(lang, "explore.title"), "explore", template.HTML(buf.String()))
}
func containsTopic(topics []string, topic string) bool {
cmd/gitfed-web/handlers_landing.go
diff --git a/cmd/gitfed-web/handlers_landing.go b/cmd/gitfed-web/handlers_landing.go
new file mode 100644
index 0000000..a4a1a85
--- /dev/null
+++ b/cmd/gitfed-web/handlers_landing.go
@@ -0,0 +1,95 @@
+package main
+
+import (
+ "bytes"
+ "html/template"
+ "net/http"
+
+ "gitfed/internal/i18n"
+)
+
+// landingTpl is the philosophy/pitch page served at "/" — what gitfed is
+// for, not what it does. The public repo browser lives at /explore.
+var landingTpl = newTpl("landing", `
+<section class="hero">
+ <span class="kicker">{{icon "network"}} {{t .Lang "landing.kicker"}}</span>
+ <h1>{{t .Lang "landing.h1_1"}} <em>{{t .Lang "landing.h1_2"}}</em></h1>
+ <p class="lead">{{t .Lang "landing.lead"}}</p>
+ <div class="hero-actions">
+ <a class="btn btn-primary" href="/explore">{{t .Lang "landing.cta_explore"}}</a>
+ <a class="btn btn-secondary" href="/security">{{t .Lang "landing.cta_security"}}</a>
+ </div>
+</section>
+
+<div class="pillars">
+ <div class="pillar">
+ <div class="picon">{{icon "lock"}}</div>
+ <h3>{{t .Lang "landing.pillar1_h"}}</h3>
+ <p>{{t .Lang "landing.pillar1_p"}}</p>
+ </div>
+ <div class="pillar">
+ <div class="picon">{{icon "network"}}</div>
+ <h3>{{t .Lang "landing.pillar2_h"}}</h3>
+ <p>{{t .Lang "landing.pillar2_p"}}</p>
+ </div>
+ <div class="pillar">
+ <div class="picon">{{icon "sliders"}}</div>
+ <h3>{{t .Lang "landing.pillar3_h"}}</h3>
+ <p>{{t .Lang "landing.pillar3_p"}}</p>
+ </div>
+</div>
+
+<div class="split">
+ <div>
+ <h2>{{t .Lang "landing.split_h"}}</h2>
+ <p>{{t .Lang "landing.split_p"}}</p>
+ <div class="links">
+ <a href="/security">{{t .Lang "landing.split_link_security"}} {{icon "arrow"}}</a>
+ <a href="/explore">{{t .Lang "landing.split_link_explore"}} {{icon "arrow"}}</a>
+ </div>
+ </div>
+ <div class="fed-diagram">
+ <svg viewBox="0 0 340 190" xmlns="http://www.w3.org/2000/svg">
+ <rect x="10" y="20" width="130" height="60" rx="4" fill="var(--surface-2)" stroke="var(--border-strong)"/>
+ <text x="75" y="45" text-anchor="middle" fill="var(--text)" font-size="11" font-weight="700">{{t .Lang "landing.diagram_you"}}</text>
+ <text x="75" y="62" text-anchor="middle" fill="var(--text-dim)" font-size="9" font-family="monospace">alice@chez-moi.fr</text>
+ <rect x="200" y="20" width="130" height="60" rx="4" fill="var(--surface-2)" stroke="var(--border-strong)"/>
+ <text x="265" y="45" text-anchor="middle" fill="var(--text)" font-size="11" font-weight="700">{{t .Lang "landing.diagram_friend"}}</text>
+ <text x="265" y="62" text-anchor="middle" fill="var(--text-dim)" font-size="9" font-family="monospace">bob@ailleurs.net</text>
+ <path d="M140 50h56" stroke="var(--accent)" stroke-width="1.5" marker-end="url(#lp-arr1)"/>
+ <path d="M200 60l-56 0" stroke="var(--accent)" stroke-width="1.5" marker-end="url(#lp-arr1)" transform="translate(0,10)"/>
+ <text x="170" y="45" text-anchor="middle" fill="var(--accent)" font-size="8.5" font-family="monospace">{{t .Lang "landing.diagram_cert"}}</text>
+ <text x="170" y="80" text-anchor="middle" fill="var(--ok-fg)" font-size="8.5" font-family="monospace">{{t .Lang "landing.diagram_trust"}}</text>
+ <rect x="105" y="120" width="130" height="50" rx="4" fill="var(--surface)" stroke="var(--border)" stroke-dasharray="3 3"/>
+ <text x="170" y="140" text-anchor="middle" fill="var(--text-faint)" font-size="9" font-family="monospace">{{t .Lang "landing.diagram_no_account1"}}</text>
+ <text x="170" y="154" text-anchor="middle" fill="var(--text-faint)" font-size="9" font-family="monospace">{{t .Lang "landing.diagram_no_account2"}}</text>
+ <defs><marker id="lp-arr1" markerWidth="7" markerHeight="7" refX="5" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="var(--accent)"/></marker></defs>
+ </svg>
+ </div>
+</div>
+
+<div class="cta-band">
+ <h2>{{t .Lang "landing.cta_band_h"}}</h2>
+ <p>{{t .Lang "landing.cta_band_p"}}</p>
+ <div class="hero-actions">
+ {{if .LoggedIn}}
+ <a class="btn btn-primary" href="/dashboard">{{t .Lang "landing.cta_band_dashboard"}}</a>
+ {{else}}
+ <a class="btn btn-primary" href="/explore">{{t .Lang "landing.cta_explore"}}</a>
+ <a class="btn btn-secondary" href="/login">{{t .Lang "nav.login"}}</a>
+ {{end}}
+ </div>
+</div>
+`)
+
+func (s *server) handleLanding(w http.ResponseWriter, r *http.Request) {
+ lang := s.lang(r)
+ _, loggedIn := s.currentSession(r)
+
+ var buf bytes.Buffer
+ _ = landingTpl.Execute(&buf, struct {
+ Lang string
+ LoggedIn bool
+ }{string(lang), loggedIn})
+ s.render(w, r, i18n.T(lang, "landing.title"), "home", template.HTML(buf.String()))
+}
cmd/gitfed-web/handlers_repo.go
diff --git a/cmd/gitfed-web/handlers_repo.go b/cmd/gitfed-web/handlers_repo.go
index 595e0af..0f1ccea 100644
--- a/cmd/gitfed-web/handlers_repo.go
+++ b/cmd/gitfed-web/handlers_repo.go
@@ -8,6 +8,7 @@ import (
"strings"
"gitfed/internal/gitexec"
+ "gitfed/internal/i18n"
"gitfed/internal/store"
)
@@ -73,24 +74,24 @@ type treeEntryView struct {
// repoTpl renders the repo's file tree at the current path, GitLab-style:
// files/dirs at the top, README (and, root only, LICENSE) rendered directly
// below — no separate "browse files" page.
-var repoTpl = template.Must(template.New("repo").Parse(`
+var repoTpl = newTpl("repo", `
{{.Flash}}
-<div class="gf-crumbs"><a href="/">Explore</a><span class="sep">/</span>{{.Repo.Name}}</div>
+<div class="gf-crumbs"><a href="/explore">{{t .Lang "nav.explore"}}</a><span class="sep">/</span>{{.Repo.Name}}</div>
<div class="gf-repo-head">
<h1>{{.Repo.Name}}</h1>
- {{if .Repo.Public}}<span class="badge trusted">public</span>{{else}}<span class="badge pending">private</span>{{end}}
+ {{if .Repo.Public}}<span class="badge trusted">{{t .Lang "common.public"}}</span>{{else}}<span class="badge pending">{{t .Lang "common.private"}}</span>{{end}}
{{range .Repo.Topics}}<span class="badge plain">{{.}}</span>{{end}}
</div>
-<p class="muted">owner: {{.Repo.Owner}}</p>
+<p class="muted">{{t .Lang "repo.owner"}}: {{.Repo.Owner}}</p>
<div class="gf-action-bar">
- {{if .Branch}}<span class="gf-btn">⎇ {{.Branch}}</span>{{end}}
+ {{if .Branch}}<span class="gf-btn">{{icon "branch"}} {{.Branch}}</span>{{end}}
<div class="gf-clone-url">
<code>{{.CloneURL}}</code>
- <button type="button" class="linklike" data-copy="{{.CloneURL}}" title="Copy clone URL" aria-label="Copy clone URL">⧉</button>
+ <button type="button" class="linklike" data-copy="{{.CloneURL}}" title="{{t .Lang "repo.copy_clone_url"}}" aria-label="{{t .Lang "repo.copy_clone_url"}}">{{icon "copy"}}</button>
</div>
- {{if .CanAdminister}}<a href="/repo-settings/{{.Repo.Name}}" class="gf-btn">Settings</a>{{end}}
+ {{if .CanAdminister}}<a href="/repo-settings/{{.Repo.Name}}" class="gf-btn">{{t .Lang "nav.settings"}}</a>{{end}}
</div>
{{if .Crumbs}}
@@ -101,48 +102,49 @@ var repoTpl = template.Must(template.New("repo").Parse(`
{{if .Empty}}
<div class="gf-card" style="padding: 1.5rem;">
- <p class="muted" style="margin: 0 0 0.6rem;">This repository is empty.</p>
+ <p class="muted" style="margin: 0 0 0.6rem;">{{t .Lang "repo.empty"}}</p>
<code>git clone {{.CloneURL}}</code>
</div>
{{else}}
<div class="gf-card">
<table class="gf-file-table">
- {{if .ShowUp}}<tr><td class="icon">📁</td><td class="name"><a href="/r/{{.Repo.Name}}?path={{.ParentPath}}">..</a></td><td class="meta"></td></tr>{{end}}
+ {{if .ShowUp}}<tr><td class="icon">{{icon "folder"}}</td><td class="name"><a href="/r/{{.Repo.Name}}?path={{.ParentPath}}">..</a></td><td class="meta"></td></tr>{{end}}
{{range .Entries}}
{{if eq .Type "tree"}}
- <tr><td class="icon">📁</td><td class="name"><a href="/r/{{$.Repo.Name}}?path={{.FullPath}}">{{.Name}}</a></td><td class="meta">directory</td></tr>
+ <tr><td class="icon">{{icon "folder"}}</td><td class="name"><a href="/r/{{$.Repo.Name}}?path={{.FullPath}}">{{.Name}}</a></td><td class="meta">{{t $.Lang "repo.directory"}}</td></tr>
{{else}}
- <tr><td class="icon">📄</td><td class="name"><a href="/repo-blob/{{$.Repo.Name}}?path={{.FullPath}}">{{.Name}}</a></td><td class="meta">file</td></tr>
+ <tr><td class="icon">{{icon "file"}}</td><td class="name"><a href="/repo-blob/{{$.Repo.Name}}?path={{.FullPath}}">{{.Name}}</a></td><td class="meta">{{t $.Lang "repo.file"}}</td></tr>
{{end}}
{{else}}
- {{if not .ShowUp}}<tr><td colspan="3" class="muted" style="padding:0.9rem;">Nothing here.</td></tr>{{end}}
+ {{if not .ShowUp}}<tr><td colspan="3" class="muted" style="padding:0.9rem;">{{t .Lang "repo.nothing_here"}}</td></tr>{{end}}
{{end}}
</table>
</div>
{{end}}
{{if .Tags}}
-<section><h3>Tags</h3>{{range .Tags}}<span class="badge plain">{{.}}</span> {{end}}</section>
+<section><h3>{{t .Lang "repo.tags"}}</h3>{{range .Tags}}<span class="badge plain">{{.}}</span> {{end}}</section>
{{end}}
{{if .ReadmeHTML}}
<div class="gf-card">
- <div class="gf-readme-head">📄 README</div>
+ <div class="gf-readme-head">{{icon "file"}} README</div>
<div class="gf-readme-body markdown-body">{{.ReadmeHTML}}</div>
</div>
{{end}}
{{if .LicenseHTML}}
<div class="gf-card">
- <div class="gf-readme-head">📄 License ({{.LicenseFile}})</div>
+ <div class="gf-readme-head">{{icon "file"}} {{t .Lang "repo.license"}} ({{.LicenseFile}})</div>
<div class="gf-readme-body markdown-body">{{.LicenseHTML}}</div>
</div>
{{end}}
-`))
+`)
func (s *server) handleRepoView(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("repo")
path := strings.Trim(r.URL.Query().Get("path"), "/")
+ lang := s.lang(r)
repo, err := s.ops.GetRepo(name)
if err != nil || !s.canView(r, repo) {
@@ -153,7 +155,7 @@ func (s *server) handleRepoView(w http.ResponseWriter, r *http.Request) {
entries, found, err := s.ops.ListRepoTree(name, path)
if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ s.serverError(w, r, err)
return
}
if !found && path != "" {
@@ -175,7 +177,7 @@ func (s *server) handleRepoView(w http.ResponseWriter, r *http.Request) {
branch, _, err := s.ops.GetRepoBranch(name)
if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ s.serverError(w, r, err)
return
}
@@ -185,34 +187,34 @@ func (s *server) handleRepoView(w http.ResponseWriter, r *http.Request) {
if path == "" {
readmeContent, readmeFound, err := s.ops.GetRepoReadme(name)
if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ s.serverError(w, r, err)
return
}
if readmeFound {
readmeHTML, err = renderFileContent("README.md", readmeContent)
if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ s.serverError(w, r, err)
return
}
}
licenseContent, foundLicenseFile, licenseFound, err := s.ops.GetRepoLicense(name)
if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ s.serverError(w, r, err)
return
}
if licenseFound {
licenseFile = foundLicenseFile
licenseHTML, err = renderFileContent(licenseFile, licenseContent)
if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ s.serverError(w, r, err)
return
}
}
tags, err = s.ops.ListRepoTags(name)
if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ s.serverError(w, r, err)
return
}
}
@@ -223,6 +225,7 @@ func (s *server) handleRepoView(w http.ResponseWriter, r *http.Request) {
Domain string
CloneURL string
Branch string
+ Lang string
Crumbs []crumb
Entries []treeEntryView
ShowUp bool
@@ -235,7 +238,7 @@ func (s *server) handleRepoView(w http.ResponseWriter, r *http.Request) {
CanAdminister bool
Flash template.HTML
}{
- repo, s.domain, "ssh://git@" + s.domain + ":2222/" + name + ".git", branch,
+ repo, s.domain, "ssh://git@" + s.domain + ":2222/" + name + ".git", branch, string(lang),
breadcrumbs(path), views, path != "", parentPath(path), path == "" && !found,
tags, readmeHTML, licenseHTML, licenseFile, canAdminister, flash(r),
})
@@ -244,10 +247,10 @@ func (s *server) handleRepoView(w http.ResponseWriter, r *http.Request) {
if path != "" {
title = path + " — " + name
}
- s.renderWide(w, r, title, "home", template.HTML(buf.String()))
+ s.render(w, r, title, "home", template.HTML(buf.String()))
}
-var blobTpl = template.Must(template.New("blob").Parse(`
+var blobTpl = newTpl("blob", `
{{.Flash}}
<div class="gf-crumbs">
<a href="/r/{{.Repo}}">{{.Repo}}</a>{{range .Crumbs}} / {{if .Last}}{{.Name}}{{else}}<a href="/r/{{$.Repo}}?path={{.Path}}">{{.Name}}</a>{{end}}{{end}}
@@ -257,11 +260,12 @@ var blobTpl = template.Must(template.New("blob").Parse(`
{{.Content}}
</div>
</div>
-`))
+`)
func (s *server) handleRepoBlob(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("repo")
path := strings.Trim(r.URL.Query().Get("path"), "/")
+ lang := s.lang(r)
if path == "" {
http.NotFound(w, r)
return
@@ -275,7 +279,7 @@ func (s *server) handleRepoBlob(w http.ResponseWriter, r *http.Request) {
content, found, err := s.ops.GetRepoFile(name, path)
if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ s.serverError(w, r, err)
return
}
if !found {
@@ -285,7 +289,7 @@ func (s *server) handleRepoBlob(w http.ResponseWriter, r *http.Request) {
var rendered template.HTML
if strings.Contains(content, "\x00") {
- rendered = "<p class=\"muted\">Binary file — not shown.</p>"
+ rendered = template.HTML(`<p class="muted">` + template.HTMLEscapeString(i18n.T(lang, "repo.binary_file")) + `</p>`)
} else {
fileName := path
if i := strings.LastIndex(fileName, "/"); i >= 0 {
@@ -293,7 +297,7 @@ func (s *server) handleRepoBlob(w http.ResponseWriter, r *http.Request) {
}
rendered, err = renderFileContent(fileName, content)
if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ s.serverError(w, r, err)
return
}
}
@@ -306,7 +310,7 @@ func (s *server) handleRepoBlob(w http.ResponseWriter, r *http.Request) {
Flash template.HTML
}{name, breadcrumbs(path), rendered, flash(r)})
- s.renderWide(w, r, path+" — "+name, "home", template.HTML(buf.String()))
+ s.render(w, r, path+" — "+name, "home", template.HTML(buf.String()))
}
func renderFileContent(filename, content string) (template.HTML, error) {
@@ -322,34 +326,34 @@ func renderFileContent(filename, content string) (template.HTML, error) {
var plainTpl = template.Must(template.New("plain").Parse(`<pre>{{.}}</pre>`))
-var repoSettingsTpl = template.Must(template.New("repo-settings").Parse(`
+var repoSettingsTpl = newTpl("repo-settings", `
{{.Flash}}
<p><a href="/r/{{.Repo}}">← {{.Repo}}</a></p>
-<h1>{{.Repo}} settings</h1>
+<h1>{{.Repo}} {{t .Lang "repo.settings_title"}}</h1>
<form class="card" method="post" action="/repo-settings/{{.Repo}}">
- <strong>Visibility & topics</strong>
- <label><input type="checkbox" name="public" value="1" style="width:auto; display:inline-block;" {{if .Public}}checked{{end}}> Public (readable by any authenticated principal)</label>
- <label>Topics (comma-separated)</label>
+ <strong>{{t .Lang "repo.visibility_topics"}}</strong>
+ <label><input type="checkbox" name="public" value="1" style="width:auto; display:inline-block;" {{if .Public}}checked{{end}}> {{t .Lang "repo.public_desc"}}</label>
+ <label>{{t .Lang "repo.topics_label"}}</label>
<input name="topics" value="{{.TopicsCSV}}" placeholder="cli, tooling, go">
- <button type="submit">Save</button>
+ <button type="submit">{{t .Lang "repo.save"}}</button>
</form>
<table>
-<tr><th>Principal</th><th>Role</th><th></th></tr>
+<tr><th>{{t .Lang "repo.col_principal"}}</th><th>{{t .Lang "repo.col_role"}}</th><th></th></tr>
<tr>
<td>{{.Owner}}</td>
- <td>admin</td>
- <td class="muted">owner</td>
+ <td>{{t .Lang "role.admin"}}</td>
+ <td class="muted">{{t .Lang "role.owner"}}</td>
</tr>
{{range .Collaborators}}
<tr>
<td>{{.Principal}}</td>
- <td>{{.Role}}</td>
+ <td>{{roleLabel $.Lang (print .Role)}}</td>
<td>
<form class="inline" method="post" action="/repo-revoke/{{$.Repo}}">
<input type="hidden" name="principal" value="{{.Principal}}">
- <button class="danger" type="submit">Revoke</button>
+ <button class="danger" type="submit">{{t $.Lang "repo.revoke"}}</button>
</form>
</td>
</tr>
@@ -357,27 +361,28 @@ var repoSettingsTpl = template.Must(template.New("repo-settings").Parse(`
</table>
<form class="card" method="post" action="/repo-grant/{{.Repo}}">
- <strong>Grant collaborator</strong>
- <label>Principal</label>
+ <strong>{{t .Lang "repo.grant_collaborator"}}</strong>
+ <label>{{t .Lang "repo.col_principal"}}</label>
<input name="principal" required placeholder="bob@instanceb.example">
- <label>Role</label>
+ <label>{{t .Lang "repo.col_role"}}</label>
<select name="role">
- <option value="read">read</option>
- <option value="write">write</option>
- <option value="admin">admin</option>
+ <option value="read">{{t .Lang "role.read"}}</option>
+ <option value="write">{{t .Lang "role.write"}}</option>
+ <option value="admin">{{t .Lang "role.admin"}}</option>
</select>
- <button type="submit">Grant</button>
+ <button type="submit">{{t .Lang "repo.grant"}}</button>
</form>
-<p class="muted">Granting a collaborator on a domain not yet known to this instance triggers federation discovery of that domain's CA — an instance admin needs to approve it under Admin → Trust store (whitelist policy).</p>
+<p class="muted">{{t .Lang "repo.grant_note"}}</p>
-<form class="card" method="post" action="/repo-delete/{{.Repo}}" onsubmit="return confirm('Delete {{.Repo}}? This does not delete the bare repo on disk.');">
- <strong>Danger zone</strong>
- <button class="danger" type="submit">Delete repo record</button>
+<form class="card" method="post" action="/repo-delete/{{.Repo}}" data-confirm="{{t .Lang "repo.confirm_delete"}} {{.Repo}}?">
+ <strong>{{t .Lang "repo.danger_zone"}}</strong>
+ <button class="danger" type="submit">{{t .Lang "repo.delete_record"}}</button>
</form>
-`))
+`)
func (s *server) handleRepoSettingsForm(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("repo")
+ lang := s.lang(r)
if _, ok := s.canAdminister(r, name); !ok {
http.NotFound(w, r)
return
@@ -389,7 +394,7 @@ func (s *server) handleRepoSettingsForm(w http.ResponseWriter, r *http.Request)
}
acl, err := s.ops.GetACL(name)
if err != nil && err != store.ErrNotFound {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ s.serverError(w, r, err)
return
}
@@ -397,16 +402,18 @@ func (s *server) handleRepoSettingsForm(w http.ResponseWriter, r *http.Request)
_ = repoSettingsTpl.Execute(&buf, struct {
Repo string
Owner string
+ Lang string
Public bool
TopicsCSV string
Collaborators []store.Collaborator
Flash template.HTML
- }{name, repo.Owner, repo.Public, strings.Join(repo.Topics, ", "), acl.Collaborators, flash(r)})
- s.render(w, r, name+" settings", "home", template.HTML(buf.String()))
+ }{name, repo.Owner, string(lang), repo.Public, strings.Join(repo.Topics, ", "), acl.Collaborators, flash(r)})
+ s.render(w, r, name+" "+i18n.T(lang, "repo.settings_title"), "home", template.HTML(buf.String()))
}
func (s *server) handleRepoSettingsSave(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("repo")
+ lang := s.lang(r)
if _, ok := s.canAdminister(r, name); !ok {
http.NotFound(w, r)
return
@@ -428,11 +435,12 @@ func (s *server) handleRepoSettingsSave(w http.ResponseWriter, r *http.Request)
redirectWithMsg(w, r, back, err.Error(), true)
return
}
- redirectWithMsg(w, r, back, "saved settings", false)
+ redirectWithMsg(w, r, back, i18n.T(lang, "repo.msg_saved"), false)
}
func (s *server) handleCollabGrant(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("repo")
+ lang := s.lang(r)
if _, ok := s.canAdminister(r, name); !ok {
http.NotFound(w, r)
return
@@ -444,11 +452,12 @@ func (s *server) handleCollabGrant(w http.ResponseWriter, r *http.Request) {
redirectWithMsg(w, r, back, err.Error(), true)
return
}
- redirectWithMsg(w, r, back, "granted "+principal+" "+string(role), false)
+ redirectWithMsg(w, r, back, i18n.T(lang, "repo.msg_granted", principal, roleLabel(lang, string(role))), false)
}
func (s *server) handleCollabRevoke(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("repo")
+ lang := s.lang(r)
if _, ok := s.canAdminister(r, name); !ok {
http.NotFound(w, r)
return
@@ -459,11 +468,12 @@ func (s *server) handleCollabRevoke(w http.ResponseWriter, r *http.Request) {
redirectWithMsg(w, r, back, err.Error(), true)
return
}
- redirectWithMsg(w, r, back, "revoked "+principal, false)
+ redirectWithMsg(w, r, back, i18n.T(lang, "repo.msg_revoked", principal), false)
}
func (s *server) handleRepoDelete(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("repo")
+ lang := s.lang(r)
if _, ok := s.canAdminister(r, name); !ok {
http.NotFound(w, r)
return
@@ -472,5 +482,5 @@ func (s *server) handleRepoDelete(w http.ResponseWriter, r *http.Request) {
redirectWithMsg(w, r, "/repo-settings/"+url.PathEscape(name), err.Error(), true)
return
}
- redirectWithMsg(w, r, "/dashboard", "deleted "+name, false)
+ redirectWithMsg(w, r, "/dashboard", i18n.T(lang, "repo.msg_deleted", name), false)
}
cmd/gitfed-web/handlers_search.go
diff --git a/cmd/gitfed-web/handlers_search.go b/cmd/gitfed-web/handlers_search.go
index 0e54766..d02c554 100644
--- a/cmd/gitfed-web/handlers_search.go
+++ b/cmd/gitfed-web/handlers_search.go
@@ -6,16 +6,17 @@ import (
"net/http"
"strings"
+ "gitfed/internal/i18n"
"gitfed/internal/store"
)
-var searchTpl = template.Must(template.New("search").Parse(`
-<h1>Search{{if .Query}}: "{{.Query}}"{{end}}</h1>
+var searchTpl = newTpl("search", `
+<h1>{{t .Lang "search.title"}}{{if .Query}}: "{{.Query}}"{{end}}</h1>
<form action="/search" method="get" style="max-width:420px;">
- <input type="search" name="q" value="{{.Query}}" placeholder="Search repos…" autofocus>
+ <input type="search" name="q" value="{{.Query}}" placeholder="{{t .Lang "nav.search_placeholder"}}" autofocus>
</form>
<table>
-<tr><th>Repo</th><th>Owner</th><th>Topics</th></tr>
+<tr><th>{{t .Lang "explore.col_repo"}}</th><th>{{t .Lang "explore.col_owner"}}</th><th>{{t .Lang "explore.col_topics"}}</th></tr>
{{range .Results}}
<tr>
<td><a href="/r/{{.Name}}">{{.Name}}</a></td>
@@ -23,10 +24,10 @@ var searchTpl = template.Must(template.New("search").Parse(`
<td>{{range .Topics}}<span class="badge plain">{{.}}</span>{{end}}</td>
</tr>
{{else}}
-<tr><td colspan="3" class="muted">{{if .Query}}No matches.{{else}}Type something to search.{{end}}</td></tr>
+<tr><td colspan="3" class="muted">{{if .Query}}{{t .Lang "search.no_matches"}}{{else}}{{t .Lang "search.prompt"}}{{end}}</td></tr>
{{end}}
</table>
-`))
+`)
// searchVisible reports whether repo should be considered for search
// results for the given (possibly anonymous) session: same visibility rule
@@ -38,12 +39,13 @@ func (s *server) searchVisible(r *http.Request, repo store.Repo) bool {
func (s *server) handleSearch(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
+ lang := s.lang(r)
var results []store.Repo
if q != "" {
all, err := s.ops.ListRepos()
if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ s.serverError(w, r, err)
return
}
needle := strings.ToLower(q)
@@ -59,10 +61,10 @@ func (s *server) handleSearch(w http.ResponseWriter, r *http.Request) {
var buf bytes.Buffer
_ = searchTpl.Execute(&buf, struct {
- Query string
- Results []store.Repo
- }{q, results})
- s.render(w, r, "Search", "", template.HTML(buf.String()))
+ Query, Lang string
+ Results []store.Repo
+ }{q, string(lang), results})
+ s.render(w, r, i18n.T(lang, "search.title"), "", template.HTML(buf.String()))
}
func matchesSearch(repo store.Repo, lowerNeedle string) bool {
cmd/gitfed-web/handlers_security.go
diff --git a/cmd/gitfed-web/handlers_security.go b/cmd/gitfed-web/handlers_security.go
new file mode 100644
index 0000000..4446624
--- /dev/null
+++ b/cmd/gitfed-web/handlers_security.go
@@ -0,0 +1,128 @@
+package main
+
+import (
+ "bytes"
+ "html/template"
+ "net/http"
+
+ "gitfed/internal/i18n"
+)
+
+var securityTpl = newTpl("security", `
+<div class="sec-head">
+ <span class="kicker">{{icon "lock"}} {{t .Lang "security.kicker"}}</span>
+ <h1>{{t .Lang "security.h1"}}</h1>
+ <p>{{t .Lang "security.lead"}}</p>
+</div>
+
+<section class="sec-section">
+ <div class="tag">{{t .Lang "security.s1_tag"}}</div>
+ <h2>{{icon "key"}} {{t .Lang "security.s1_h"}}</h2>
+ <p>{{t .Lang "security.s1_p"}}</p>
+ <div class="sec-diagram">
+ <svg viewBox="0 0 460 110" xmlns="http://www.w3.org/2000/svg">
+ <rect x="10" y="30" width="110" height="50" rx="4" fill="var(--surface-2)" stroke="var(--border-strong)"/>
+ <text x="65" y="59" text-anchor="middle" fill="var(--text)" font-size="11">{{t .Lang "security.s1_diagram_client"}}</text>
+ <path d="M120 55h70" stroke="var(--ok-fg)" stroke-width="1.6" marker-end="url(#s-arr-ok)"/>
+ <text x="155" y="45" text-anchor="middle" fill="var(--ok-fg)" font-size="9" font-family="monospace">SSH :2222</text>
+ <rect x="190" y="10" width="150" height="90" rx="6" fill="var(--surface)" stroke="var(--border)"/>
+ <text x="265" y="35" text-anchor="middle" fill="var(--text)" font-size="11" font-weight="700">gitfed-server</text>
+ <rect x="205" y="48" width="120" height="24" rx="4" fill="var(--ok-bg)" stroke="var(--ok-fg)"/>
+ <text x="265" y="64" text-anchor="middle" fill="var(--ok-fg)" font-size="9" font-family="monospace">{{t .Lang "security.s1_diagram_open"}}</text>
+ <rect x="205" y="76" width="120" height="20" rx="4" fill="var(--danger-bg)" stroke="var(--danger-fg)" stroke-dasharray="3 3"/>
+ <text x="265" y="90" text-anchor="middle" fill="var(--danger-fg)" font-size="8.5" font-family="monospace">{{t .Lang "security.s1_diagram_http"}}</text>
+ <rect x="360" y="30" width="90" height="50" rx="4" fill="var(--surface-2)" stroke="var(--border-strong)"/>
+ <text x="405" y="53" text-anchor="middle" fill="var(--text)" font-size="10">{{t .Lang "security.s1_diagram_repos"}}</text>
+ <text x="405" y="66" text-anchor="middle" fill="var(--text-dim)" font-size="9">bare git</text>
+ <path d="M340 55h20" stroke="var(--border-strong)" stroke-width="1.6" marker-end="url(#s-arr-ok)"/>
+ <defs><marker id="s-arr-ok" markerWidth="7" markerHeight="7" refX="5" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="var(--ok-fg)"/></marker></defs>
+ </svg>
+ <div class="cap">{{t .Lang "security.s1_caption"}}</div>
+ </div>
+</section>
+
+<section class="sec-section">
+ <div class="tag">{{t .Lang "security.s2_tag"}}</div>
+ <h2>{{icon "shield"}} {{t .Lang "security.s2_h"}}</h2>
+ <p>{{t .Lang "security.s2_p"}}</p>
+ <div class="sec-diagram">
+ <svg viewBox="0 0 520 190" xmlns="http://www.w3.org/2000/svg">
+ <circle cx="60" cy="95" r="34" fill="var(--surface-2)" stroke="var(--border-strong)"/>
+ <text x="60" y="92" text-anchor="middle" fill="var(--text)" font-size="10" font-weight="700">alice</text>
+ <text x="60" y="104" text-anchor="middle" fill="var(--text-dim)" font-size="8">{{t .Lang "security.s2_diagram_haskey"}}</text>
+ <path d="M96 95h48" stroke="var(--accent)" stroke-width="1.5" marker-end="url(#s-arr-a)"/>
+ <text x="120" y="86" text-anchor="middle" fill="var(--accent)" font-size="8" font-family="monospace">{{t .Lang "security.s2_diagram_step1"}}</text>
+ <rect x="150" y="55" width="120" height="80" rx="6" fill="var(--surface)" stroke="var(--border)"/>
+ <text x="210" y="78" text-anchor="middle" fill="var(--text)" font-size="10" font-weight="700">{{t .Lang "security.s2_diagram_alice_instance"}}</text>
+ <text x="210" y="94" text-anchor="middle" fill="var(--text-dim)" font-size="8.5" font-family="monospace">{{t .Lang "security.s2_diagram_local_ca"}}</text>
+ <rect x="170" y="102" width="80" height="20" rx="10" fill="rgba(108,157,245,0.12)" stroke="var(--accent)"/>
+ <text x="210" y="116" text-anchor="middle" fill="var(--accent)" font-size="8" font-family="monospace">{{t .Lang "security.s2_diagram_signs"}}</text>
+ <path d="M270 95h48" stroke="var(--accent)" stroke-width="1.5" marker-end="url(#s-arr-a)"/>
+ <text x="294" y="86" text-anchor="middle" fill="var(--accent)" font-size="8" font-family="monospace">{{t .Lang "security.s2_diagram_step2"}}</text>
+ <rect x="318" y="55" width="120" height="80" rx="6" fill="var(--surface)" stroke="var(--border)"/>
+ <text x="378" y="78" text-anchor="middle" fill="var(--text)" font-size="10" font-weight="700">{{t .Lang "security.s2_diagram_bob_instance"}}</text>
+ <text x="378" y="94" text-anchor="middle" fill="var(--text-dim)" font-size="8.5" font-family="monospace">{{t .Lang "security.s2_diagram_trust1"}}</text>
+ <text x="378" y="106" text-anchor="middle" fill="var(--text-dim)" font-size="8.5" font-family="monospace">{{t .Lang "security.s2_diagram_trust2"}}</text>
+ <path d="M438 95h30" stroke="var(--ok-fg)" stroke-width="1.5" marker-end="url(#s-arr-ok2)"/>
+ <circle cx="488" cy="95" r="20" fill="var(--ok-bg)" stroke="var(--ok-fg)"/>
+ <text x="488" y="99" text-anchor="middle" fill="var(--ok-fg)" font-size="9" font-weight="700">{{t .Lang "security.s2_diagram_access"}}</text>
+ <text x="210" y="160" text-anchor="middle" fill="var(--text-faint)" font-size="8.5" font-family="monospace">{{t .Lang "security.s2_diagram_local1"}}</text>
+ <text x="210" y="172" text-anchor="middle" fill="var(--text-faint)" font-size="8.5" font-family="monospace">{{t .Lang "security.s2_diagram_local2"}}</text>
+ <defs>
+ <marker id="s-arr-a" markerWidth="7" markerHeight="7" refX="5" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="var(--accent)"/></marker>
+ <marker id="s-arr-ok2" markerWidth="7" markerHeight="7" refX="5" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="var(--ok-fg)"/></marker>
+ </defs>
+ </svg>
+ <div class="cap">{{t .Lang "security.s2_caption"}}</div>
+ </div>
+</section>
+
+<section class="sec-section">
+ <div class="tag">{{t .Lang "security.s3_tag"}}</div>
+ <h2>{{icon "network"}} {{t .Lang "security.s3_h"}}</h2>
+ <p>{{t .Lang "security.s3_p"}}</p>
+ <div class="sec-diagram">
+ <svg viewBox="0 0 560 90" xmlns="http://www.w3.org/2000/svg">
+ <rect x="0" y="15" width="118" height="46" rx="6" fill="var(--surface-2)" stroke="var(--border-strong)"/>
+ <text x="59" y="34" text-anchor="middle" fill="var(--text)" font-size="9.5" font-weight="700">{{t .Lang "security.s3_diagram_unknown"}}</text>
+ <text x="59" y="48" text-anchor="middle" fill="var(--text-dim)" font-size="8">{{t .Lang "security.s3_diagram_unknown_sub"}}</text>
+ <path d="M118 38h34" stroke="var(--text-faint)" stroke-width="1.5" marker-end="url(#s-arr-g)"/>
+ <rect x="152" y="15" width="118" height="46" rx="6" fill="var(--surface-2)" stroke="var(--border-strong)"/>
+ <text x="211" y="34" text-anchor="middle" fill="var(--text)" font-size="9.5" font-weight="700">{{t .Lang "security.s3_diagram_discovery"}}</text>
+ <text x="211" y="48" text-anchor="middle" fill="var(--text-dim)" font-size="8">{{t .Lang "security.s3_diagram_discovery_sub"}}</text>
+ <path d="M270 38h34" stroke="var(--pending-fg)" stroke-width="1.5" marker-end="url(#s-arr-y)"/>
+ <rect x="304" y="15" width="118" height="46" rx="6" fill="var(--pending-bg)" stroke="var(--pending-fg)"/>
+ <text x="363" y="34" text-anchor="middle" fill="var(--pending-fg)" font-size="9.5" font-weight="700">{{t .Lang "security.s3_diagram_pending"}}</text>
+ <text x="363" y="48" text-anchor="middle" fill="var(--pending-fg)" font-size="8">{{t .Lang "security.s3_diagram_pending_sub"}}</text>
+ <path d="M422 38h34" stroke="var(--ok-fg)" stroke-width="1.5" marker-end="url(#s-arr-g2)"/>
+ <rect x="456" y="15" width="104" height="46" rx="6" fill="var(--ok-bg)" stroke="var(--ok-fg)"/>
+ <text x="508" y="34" text-anchor="middle" fill="var(--ok-fg)" font-size="9.5" font-weight="700">{{t .Lang "security.s3_diagram_approved"}}</text>
+ <text x="508" y="48" text-anchor="middle" fill="var(--ok-fg)" font-size="8">{{t .Lang "security.s3_diagram_approved_sub"}}</text>
+ <defs>
+ <marker id="s-arr-g" markerWidth="7" markerHeight="7" refX="5" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="var(--text-faint)"/></marker>
+ <marker id="s-arr-y" markerWidth="7" markerHeight="7" refX="5" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="var(--pending-fg)"/></marker>
+ <marker id="s-arr-g2" markerWidth="7" markerHeight="7" refX="5" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="var(--ok-fg)"/></marker>
+ </defs>
+ </svg>
+ </div>
+</section>
+
+<section class="sec-section">
+ <div class="tag">{{t .Lang "security.s4_tag"}}</div>
+ <h2>{{icon "sliders"}} {{t .Lang "security.s4_h"}}</h2>
+ <p>{{t .Lang "security.s4_p"}}</p>
+ <div class="sec-facts">
+ <div class="sec-fact">{{icon "check"}}<span><strong>{{t .Lang "security.s4_fact1_h"}}</strong> — {{t .Lang "security.s4_fact1_p"}}</span></div>
+ <div class="sec-fact">{{icon "check"}}<span><strong>{{t .Lang "security.s4_fact2_h"}}</strong> — {{t .Lang "security.s4_fact2_p"}}</span></div>
+ <div class="sec-fact">{{icon "check"}}<span><strong>{{t .Lang "security.s4_fact3_h"}}</strong> — {{t .Lang "security.s4_fact3_p"}}</span></div>
+ <div class="sec-fact">{{icon "check"}}<span><strong>{{t .Lang "security.s4_fact4_h"}}</strong> — {{t .Lang "security.s4_fact4_p"}}</span></div>
+ </div>
+</section>
+`)
+
+func (s *server) handleSecurity(w http.ResponseWriter, r *http.Request) {
+ lang := s.lang(r)
+ var buf bytes.Buffer
+ _ = securityTpl.Execute(&buf, struct{ Lang string }{string(lang)})
+ s.render(w, r, i18n.T(lang, "security.title"), "security", template.HTML(buf.String()))
+}
cmd/gitfed-web/handlers_settings.go
diff --git a/cmd/gitfed-web/handlers_settings.go b/cmd/gitfed-web/handlers_settings.go
index 1495dd2..78f65eb 100644
--- a/cmd/gitfed-web/handlers_settings.go
+++ b/cmd/gitfed-web/handlers_settings.go
@@ -4,21 +4,23 @@ import (
"bytes"
"html/template"
"net/http"
+
+ "gitfed/internal/i18n"
)
-var settingsTpl = template.Must(template.New("settings").Parse(`
+var settingsTpl = newTpl("settings", `
{{.Flash}}
-<div class="gf-page-head"><h1>Settings</h1></div>
+<div class="gf-page-head"><h1>{{t .Lang "settings.title"}}</h1></div>
<div class="gf-tabs">
- <button type="button" data-tab="tab-profile" class="{{if eq .ActiveTab "profile"}}active{{end}}">Profile</button>
- <button type="button" data-tab="tab-keys" class="{{if eq .ActiveTab "keys"}}active{{end}}">SSH keys</button>
- <button type="button" data-tab="tab-password" class="{{if eq .ActiveTab "password"}}active{{end}}">Password</button>
+ <button type="button" data-tab="tab-profile" class="{{if eq .ActiveTab "profile"}}active{{end}}">{{t .Lang "settings.tab_profile"}}</button>
+ <button type="button" data-tab="tab-keys" class="{{if eq .ActiveTab "keys"}}active{{end}}">{{t .Lang "settings.tab_keys"}}</button>
+ <button type="button" data-tab="tab-password" class="{{if eq .ActiveTab "password"}}active{{end}}">{{t .Lang "settings.tab_password"}}</button>
</div>
<div id="tab-profile" class="gf-tabpane {{if eq .ActiveTab "profile"}}active{{end}}">
- <p class="muted">Logged in as <strong>{{.Username}}</strong>{{if .IsAdmin}} — admin{{end}}.</p>
- <p class="muted">This password only signs you into the web UI. Git push/pull always goes over SSH with a key, independently of it.</p>
+ <p class="muted">{{t .Lang "settings.logged_in_as"}} <strong>{{.Username}}</strong>{{if .IsAdmin}} — {{t .Lang "role.admin"}}{{end}}.</p>
+ <p class="muted">{{t .Lang "settings.profile_note"}}</p>
</div>
<div id="tab-keys" class="gf-tabpane {{if eq .ActiveTab "keys"}}active{{end}}">
@@ -28,33 +30,33 @@ var settingsTpl = template.Must(template.New("settings").Parse(`
<code>{{.Short}}</code>
<form class="inline" method="post" action="/settings/keys/remove">
<input type="hidden" name="key" value="{{.Full}}">
- <button class="danger" type="submit">Remove</button>
+ <button class="danger" type="submit">{{t $.Lang "settings.remove"}}</button>
</form>
</div>
{{else}}
- <div class="gf-key-row"><span class="muted">No keys yet.</span></div>
+ <div class="gf-key-row"><span class="muted">{{t .Lang "settings.no_keys"}}</span></div>
{{end}}
</div>
<form class="card" method="post" action="/settings/keys/add">
- <strong>Add SSH key</strong>
- <label>Public key (authorized_keys format)</label>
+ <strong>{{t .Lang "settings.add_key"}}</strong>
+ <label>{{t .Lang "settings.pubkey_label"}}</label>
<input name="pubkey" required placeholder="ssh-ed25519 AAAA... comment">
- <button type="submit">Add</button>
+ <button type="submit">{{t .Lang "settings.add"}}</button>
</form>
</div>
<div id="tab-password" class="gf-tabpane {{if eq .ActiveTab "password"}}active{{end}}">
<form class="card" method="post" action="/settings/password">
- <strong>Change password</strong>
- <label>Current password</label>
+ <strong>{{t .Lang "settings.change_password"}}</strong>
+ <label>{{t .Lang "settings.current_password"}}</label>
<input name="old_password" type="password" required>
- <label>New password</label>
+ <label>{{t .Lang "settings.new_password"}}</label>
<input name="new_password" type="password" required minlength="8">
- <button type="submit">Change password</button>
+ <button type="submit">{{t .Lang "settings.change_password"}}</button>
</form>
</div>
-`))
+`)
type keyView struct{ Short, Full string }
@@ -71,9 +73,10 @@ func settingsTab(r *http.Request) string {
func (s *server) handleSettings(w http.ResponseWriter, r *http.Request) {
sess, _ := s.currentSession(r)
+ lang := s.lang(r)
user, err := s.ops.GetUser(sess.Username)
if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ s.serverError(w, r, err)
return
}
@@ -88,34 +91,37 @@ func (s *server) handleSettings(w http.ResponseWriter, r *http.Request) {
var buf bytes.Buffer
_ = settingsTpl.Execute(&buf, struct {
- Username, ActiveTab string
- IsAdmin bool
- Keys []keyView
- Flash template.HTML
- }{sess.Username, settingsTab(r), sess.IsAdmin, keys, flash(r)})
- s.render(w, r, "Settings", "settings", template.HTML(buf.String()))
+ Username, ActiveTab, Lang string
+ IsAdmin bool
+ Keys []keyView
+ Flash template.HTML
+ }{sess.Username, settingsTab(r), string(lang), sess.IsAdmin, keys, flash(r)})
+ s.render(w, r, i18n.T(lang, "settings.title"), "settings", template.HTML(buf.String()))
}
func (s *server) handleAddOwnKey(w http.ResponseWriter, r *http.Request) {
sess, _ := s.currentSession(r)
+ lang := s.lang(r)
if err := s.ops.AddUserKey(sess.Username, r.FormValue("pubkey")); err != nil {
redirectWithMsg(w, r, "/settings?tab=keys", err.Error(), true)
return
}
- redirectWithMsg(w, r, "/settings?tab=keys", "key added", false)
+ redirectWithMsg(w, r, "/settings?tab=keys", i18n.T(lang, "settings.msg_key_added"), false)
}
func (s *server) handleRemoveOwnKey(w http.ResponseWriter, r *http.Request) {
sess, _ := s.currentSession(r)
+ lang := s.lang(r)
if err := s.ops.RemoveUserKey(sess.Username, r.FormValue("key")); err != nil {
redirectWithMsg(w, r, "/settings?tab=keys", err.Error(), true)
return
}
- redirectWithMsg(w, r, "/settings?tab=keys", "key removed", false)
+ redirectWithMsg(w, r, "/settings?tab=keys", i18n.T(lang, "settings.msg_key_removed"), false)
}
func (s *server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
sess, _ := s.currentSession(r)
+ lang := s.lang(r)
oldPassword := r.FormValue("old_password")
newPassword := r.FormValue("new_password")
@@ -125,12 +131,12 @@ func (s *server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
return
}
if !ok {
- redirectWithMsg(w, r, "/settings?tab=password", "current password is incorrect", true)
+ redirectWithMsg(w, r, "/settings?tab=password", i18n.T(lang, "settings.msg_wrong_password"), true)
return
}
if err := s.ops.SetPassword(sess.Username, newPassword); err != nil {
redirectWithMsg(w, r, "/settings?tab=password", err.Error(), true)
return
}
- redirectWithMsg(w, r, "/settings?tab=password", "password changed", false)
+ redirectWithMsg(w, r, "/settings?tab=password", i18n.T(lang, "settings.msg_password_changed"), false)
}
cmd/gitfed-web/lang.go
diff --git a/cmd/gitfed-web/lang.go b/cmd/gitfed-web/lang.go
new file mode 100644
index 0000000..3d8fecb
--- /dev/null
+++ b/cmd/gitfed-web/lang.go
@@ -0,0 +1,51 @@
+package main
+
+import (
+ "net/http"
+ "strings"
+ "time"
+
+ "gitfed/internal/i18n"
+)
+
+const langCookieName = "gitfed_lang"
+
+// lang resolves the UI language for a request: an explicit cookie (set via
+// the nav switcher) wins, then the browser's Accept-Language header, then
+// i18n.Default.
+func (s *server) lang(r *http.Request) i18n.Lang {
+ if c, err := r.Cookie(langCookieName); err == nil {
+ if l, ok := i18n.ParseLang(c.Value); ok {
+ return l
+ }
+ }
+ for _, part := range strings.Split(r.Header.Get("Accept-Language"), ",") {
+ tag := strings.ToLower(strings.TrimSpace(strings.SplitN(part, ";", 2)[0]))
+ tag, _, _ = strings.Cut(tag, "-")
+ if l, ok := i18n.ParseLang(tag); ok {
+ return l
+ }
+ }
+ return i18n.Default
+}
+
+// handleSetLang stores the chosen language in a cookie and bounces back to
+// wherever the switcher was clicked from.
+func (s *server) handleSetLang(w http.ResponseWriter, r *http.Request) {
+ if _, ok := i18n.ParseLang(r.PathValue("lang")); ok {
+ http.SetCookie(w, &http.Cookie{
+ Name: langCookieName,
+ Value: r.PathValue("lang"),
+ Path: "/",
+ HttpOnly: true,
+ Secure: true,
+ SameSite: http.SameSiteLaxMode,
+ Expires: time.Now().Add(365 * 24 * time.Hour),
+ })
+ }
+ next := r.URL.Query().Get("next")
+ if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") {
+ next = "/"
+ }
+ http.Redirect(w, r, next, http.StatusSeeOther)
+}
cmd/gitfed-web/main.go
diff --git a/cmd/gitfed-web/main.go b/cmd/gitfed-web/main.go
index 299985e..98f8c71 100644
--- a/cmd/gitfed-web/main.go
+++ b/cmd/gitfed-web/main.go
@@ -16,6 +16,7 @@ import (
"fmt"
"net/http"
"os"
+ "time"
"gitfed/internal/admin"
"gitfed/internal/config"
@@ -25,6 +26,9 @@ import (
type server struct {
ops admin.Ops
domain string
+
+ loginByIP *loginLimiter
+ loginByUser *loginLimiter
}
func main() {
@@ -46,13 +50,35 @@ func main() {
}
defer closeFn()
- s := &server{ops: ops, domain: cfg.Domain}
+ s := &server{
+ ops: ops,
+ domain: cfg.Domain,
+ // Per-account is the tighter bound (an attacker targeting one login);
+ // per-IP is looser but catches spraying across many usernames from
+ // one source. Either tripping blocks the attempt.
+ loginByUser: newLoginLimiter(5, 15*time.Minute),
+ loginByIP: newLoginLimiter(20, 15*time.Minute),
+ }
mux := http.NewServeMux()
s.routes(mux)
+ // Security middleware wraps the whole mux: same-origin enforcement on
+ // state-changing requests (CSRF defense-in-depth on top of SameSite
+ // cookies) and the standard hardening response headers.
+ handler := securityHeaders(s.sameOriginPOST(mux))
+
+ srv := &http.Server{
+ Addr: *listen,
+ Handler: handler,
+ ReadHeaderTimeout: 5 * time.Second,
+ ReadTimeout: 15 * time.Second,
+ WriteTimeout: 30 * time.Second,
+ IdleTimeout: 60 * time.Second,
+ }
+
fmt.Printf("gitfed-web serving on %s (mode: %s)\n", *listen, mode)
- if err := http.ListenAndServe(*listen, mux); err != nil {
+ if err := srv.ListenAndServe(); err != nil {
fmt.Fprintln(os.Stderr, "gitfed-web:", err)
os.Exit(1)
}
cmd/gitfed-web/ratelimit.go
diff --git a/cmd/gitfed-web/ratelimit.go b/cmd/gitfed-web/ratelimit.go
new file mode 100644
index 0000000..0faf3d4
--- /dev/null
+++ b/cmd/gitfed-web/ratelimit.go
@@ -0,0 +1,102 @@
+package main
+
+import (
+ "net"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+)
+
+// loginLimiter is a small in-memory fixed-window rate limiter used to blunt
+// brute-force / credential-stuffing against the web login. It counts failed
+// attempts per key (client IP or username) and refuses new attempts once
+// max failures accumulate within window. It is intentionally process-local:
+// the web UI runs as a single replica (see deploy/k8s/deployment.yaml), so
+// there's no shared-state requirement, and a restart clearing the counters
+// is an acceptable, fail-open-on-restart trade-off.
+type loginLimiter struct {
+ mu sync.Mutex
+ hits map[string]*hitWindow
+ max int
+ window time.Duration
+}
+
+type hitWindow struct {
+ count int
+ reset time.Time
+}
+
+func newLoginLimiter(max int, window time.Duration) *loginLimiter {
+ l := &loginLimiter{hits: make(map[string]*hitWindow), max: max, window: window}
+ go l.gcLoop()
+ return l
+}
+
+// allowed reports whether an attempt for key may proceed right now. It does
+// not record anything — call recordFailure only on an actual auth failure,
+// so a legitimate user who logs in correctly is never counted.
+func (l *loginLimiter) allowed(key string) bool {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ w := l.hits[key]
+ if w == nil || time.Now().After(w.reset) {
+ return true
+ }
+ return w.count < l.max
+}
+
+func (l *loginLimiter) recordFailure(key string) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ now := time.Now()
+ if w := l.hits[key]; w != nil && !now.After(w.reset) {
+ w.count++
+ return
+ }
+ l.hits[key] = &hitWindow{count: 1, reset: now.Add(l.window)}
+}
+
+// reset clears the counter for key, e.g. after a successful login so the
+// window doesn't linger against a user who has proven who they are.
+func (l *loginLimiter) reset(key string) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ delete(l.hits, key)
+}
+
+func (l *loginLimiter) gcLoop() {
+ for range time.Tick(l.window) {
+ l.mu.Lock()
+ now := time.Now()
+ for k, w := range l.hits {
+ if now.After(w.reset) {
+ delete(l.hits, k)
+ }
+ }
+ l.mu.Unlock()
+ }
+}
+
+// clientIP extracts the caller's IP for rate-limiting. Behind the ingress
+// (Traefik) the real client address arrives in X-Real-IP or as the last hop
+// of X-Forwarded-For — the last entry is the one our own trusted proxy
+// appended, so it can't be spoofed by the client the way the left-most
+// (client-supplied) entry can. Falls back to the transport RemoteAddr when
+// no proxy header is present (direct connection in dev).
+func clientIP(r *http.Request) string {
+ if xr := strings.TrimSpace(r.Header.Get("X-Real-IP")); xr != "" {
+ return xr
+ }
+ if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
+ parts := strings.Split(xff, ",")
+ if last := strings.TrimSpace(parts[len(parts)-1]); last != "" {
+ return last
+ }
+ }
+ host, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err != nil {
+ return r.RemoteAddr
+ }
+ return host
+}
cmd/gitfed-web/render.go
diff --git a/cmd/gitfed-web/render.go b/cmd/gitfed-web/render.go
index 76fba94..9b879a5 100644
--- a/cmd/gitfed-web/render.go
+++ b/cmd/gitfed-web/render.go
@@ -3,6 +3,7 @@ package main
import (
"bytes"
"html/template"
+ "log"
"net/http"
"strings"
"unicode"
@@ -10,9 +11,24 @@ import (
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
+ "gitfed/internal/i18n"
"gitfed/internal/version"
)
+// commonFuncs is shared by every page template so any of them can call
+// {{t $.Lang "some.key"}} — see internal/i18n. Bound once at Parse time via
+// newTpl rather than per-request, since the language is passed as an
+// explicit argument instead of captured in a closure.
+var commonFuncs = template.FuncMap{
+ "t": func(lang, key string, args ...any) string { return i18n.T(i18n.Lang(lang), key, args...) },
+ "icon": icon,
+ "roleLabel": func(lang, role string) string { return roleLabel(i18n.Lang(lang), role) },
+}
+
+func newTpl(name, src string) *template.Template {
+ return template.Must(template.New(name).Funcs(commonFuncs).Parse(src))
+}
+
var markdown = goldmark.New(goldmark.WithExtensions(extension.GFM))
// renderMarkdown converts src to HTML. goldmark escapes any raw HTML found
@@ -33,7 +49,46 @@ func renderMarkdown(src string) (template.HTML, error) {
// favicon below.
const brandMark = `<svg class="brand-mark" width="18" height="18" viewBox="0 0 96 96" aria-hidden="true"><rect x="41" y="46" width="14" height="36" fill="currentColor"/><rect x="14" y="14" width="14" height="34" fill="currentColor" transform="rotate(35 21 31)"/><rect x="68" y="14" width="14" height="34" fill="currentColor" transform="rotate(-35 75 31)"/></svg>`
-const shellSrc = `<!doctype html>
+// iconSprite is a single SVG <symbol> sheet, injected once right after
+// <body>, that every page and every icon="ic-*" <use> in the app draws
+// from — the line-icon set that replaces emoji throughout the UI, drawn
+// with the same square-cornered vocabulary as brandMark rather than a
+// generic icon font.
+const iconSprite = `<svg width="0" height="0" style="position:absolute" aria-hidden="true">
+<defs>
+<symbol id="ic-folder" viewBox="0 0 24 24"><path d="M3 6.2c0-.66.54-1.2 1.2-1.2h4l1.6 1.8h7c.66 0 1.2.54 1.2 1.2v8.6c0 .66-.54 1.2-1.2 1.2H4.2c-.66 0-1.2-.54-1.2-1.2V6.2Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></symbol>
+<symbol id="ic-file" viewBox="0 0 24 24"><path d="M6 3.5h7l4 4v12c0 .55-.45 1-1 1H6c-.55 0-1-.45-1-1v-15c0-.55.45-1 1-1Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M13 3.5v4h4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></symbol>
+<symbol id="ic-branch" viewBox="0 0 24 24"><rect x="10.5" y="12" width="3" height="8.5" fill="currentColor"/><rect x="3.3" y="3" width="3" height="8" fill="currentColor" transform="rotate(35 4.8 7)"/><rect x="16.7" y="3" width="3" height="8" fill="currentColor" transform="rotate(-35 18.2 7)"/></symbol>
+<symbol id="ic-copy" viewBox="0 0 24 24"><rect x="8.5" y="8.5" width="10.5" height="12.5" rx="1.6" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M5.5 15V4.6c0-.6.48-1.1 1.1-1.1H16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol>
+<symbol id="ic-check" viewBox="0 0 24 24"><path d="M4.5 12.5l4.6 4.6L19.5 6.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></symbol>
+<symbol id="ic-settings" viewBox="0 0 24 24"><circle cx="12" cy="12" r="2.9" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M12 3.6v2.3M12 18.1v2.3M20.4 12h-2.3M5.9 12H3.6M17.5 6.5l-1.6 1.6M8.1 15.9l-1.6 1.6M17.5 17.5l-1.6-1.6M8.1 8.1 6.5 6.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol>
+<symbol id="ic-shield" viewBox="0 0 24 24"><path d="M12 3.4 19 6v5.6c0 4.7-3 7.9-7 9-4-1.1-7-4.3-7-9V6l7-2.6Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M9 12.2l2 2 4-4.4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></symbol>
+<symbol id="ic-logout" viewBox="0 0 24 24"><path d="M10.5 4.5H6.2C5.5 4.5 5 5 5 5.7v12.6c0 .66.54 1.2 1.2 1.2h4.3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M14.5 8.2 18.3 12l-3.8 3.8M18.3 12H9.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></symbol>
+<symbol id="ic-lock" viewBox="0 0 24 24"><rect x="5.5" y="10.5" width="13" height="10" rx="1.6" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M8 10.5V7.8a4 4 0 0 1 8 0v2.7" fill="none" stroke="currentColor" stroke-width="1.5"/></symbol>
+<symbol id="ic-key" viewBox="0 0 24 24"><circle cx="8" cy="15" r="4" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M11.3 12 20 3.3M17 6.3l2 2M14 9.3l2 2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol>
+<symbol id="ic-network" viewBox="0 0 24 24"><circle cx="12" cy="4.6" r="2.1" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="5" cy="18" r="2.1" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="19" cy="18" r="2.1" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M12 6.7v4.3M10.4 12.5 6.3 16M13.6 12.5 17.7 16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol>
+<symbol id="ic-sliders" viewBox="0 0 24 24"><path d="M5 6h9M17.5 6H19M5 12h5.5M13 12H19M5 18h9M17.5 18H19" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><circle cx="12" cy="6" r="1.8" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="9.5" cy="12" r="1.8" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="15.5" cy="18" r="1.8" fill="none" stroke="currentColor" stroke-width="1.5"/></symbol>
+<symbol id="ic-arrow" viewBox="0 0 24 24"><path d="M4 12h14.5M13 6.5l6 5.5-6 5.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></symbol>
+</defs>
+</svg>`
+
+// icon renders a use of one of iconSprite's symbols, sized to sit inline
+// with text (buttons, table cells, menu items).
+func icon(name string) template.HTML {
+ return template.HTML(`<svg class="icon" aria-hidden="true"><use href="#ic-` + name + `"/></svg>`)
+}
+
+// roleLabel translates an ACL role (or the synthetic "owner") for display.
+func roleLabel(lang i18n.Lang, role string) string {
+ switch role {
+ case "owner", "read", "write", "admin":
+ return i18n.T(lang, "role."+role)
+ default:
+ return role
+ }
+}
+
+const shellHeadSrc = `<!doctype html>
<html>
<head>
<meta charset="utf-8">
@@ -78,6 +133,12 @@ const shellSrc = `<!doctype html>
.gf-nav-right a:hover { color: var(--text); }
.linklike { background: none; border: none; color: var(--text-dim); font-size: 0.86rem; cursor: pointer; padding: 0; font-family: inherit; }
.linklike:hover { color: var(--text); }
+ .icon { width: 15px; height: 15px; flex-shrink: 0; vertical-align: -0.15em; }
+
+ /* --- language switcher --- */
+ .gf-lang { display: flex; align-items: center; background: var(--surface-2); border: 1px solid var(--border); border-radius: 999px; padding: 2px; font-size: 0.72rem; font-weight: 700; flex-shrink: 0; }
+ .gf-lang a { display: block; color: var(--text-faint); text-decoration: none; padding: 0.3rem 0.55rem; border-radius: 999px; letter-spacing: 0.02em; }
+ .gf-lang a.active { background: var(--accent); color: var(--accent-ink); }
/* --- profile dropdown --- */
.gf-avatar { width: 26px; height: 26px; border-radius: 50%; background: var(--accent-dim); color: var(--text); display: flex; align-items: center; justify-content: center; font-size: 0.68rem; font-weight: 700; flex-shrink: 0; }
@@ -112,9 +173,65 @@ const shellSrc = `<!doctype html>
/* ---------- layout ---------- */
main { padding: 1.5rem 1rem; max-width: 980px; margin: 0 auto; }
- main.wide { max-width: none; }
section { margin-bottom: 2rem; }
+ /* ---------- landing page ---------- */
+ .hero { padding: 2.6rem 0 2rem; text-align: center; }
+ .hero .kicker { display: inline-flex; align-items: center; gap: 0.45rem; font-family: var(--mono); font-size: 0.76rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--accent); background: rgba(108,157,245,0.1); border: 1px solid rgba(108,157,245,0.25); padding: 0.3rem 0.7rem; border-radius: 999px; margin-bottom: 1.4rem; }
+ .hero h1 { font-size: clamp(1.8rem, 4.4vw, 2.5rem); line-height: 1.14; margin: 0 auto 1rem; max-width: 640px; font-weight: 700; letter-spacing: -0.01em; }
+ .hero h1 em { color: var(--accent); font-style: normal; }
+ .hero p.lead { color: var(--text-dim); font-size: 1.02rem; max-width: 540px; margin: 0 auto 1.7rem; }
+ .hero-actions { display: flex; gap: 0.7rem; justify-content: center; flex-wrap: wrap; }
+ .btn { display: inline-flex; align-items: center; gap: 0.5rem; font-family: inherit; font-size: 0.9rem; font-weight: 600; padding: 0.65rem 1.15rem; border-radius: 8px; text-decoration: none; cursor: pointer; border: 1px solid transparent; margin-top: 0; }
+ .btn-primary { background: var(--accent); color: var(--accent-ink); }
+ .btn-primary:hover { filter: brightness(1.08); }
+ .btn-secondary { background: var(--surface-2); color: var(--text); border-color: var(--border-strong); }
+
+ .pillars { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; margin: 2.2rem 0; }
+ .pillar { background: var(--surface); padding: 1.5rem 1.4rem; }
+ .pillar .picon { margin-bottom: 0.8rem; color: var(--accent); }
+ .pillar .picon .icon { width: 24px; height: 24px; }
+ .pillar h3 { font-size: 1rem; margin: 0 0 0.5rem; }
+ .pillar p { color: var(--text-dim); font-size: 0.88rem; margin: 0; }
+
+ .split { display: grid; grid-template-columns: 1.1fr 1fr; gap: 2.2rem; align-items: center; margin: 2.8rem 0; }
+ .split h2 { font-size: 1.3rem; margin: 0 0 0.8rem; }
+ .split p { color: var(--text-dim); font-size: 0.92rem; margin: 0 0 0.9rem; }
+ .split .links { display: flex; gap: 1.3rem; flex-wrap: wrap; }
+ .split .links a { display: inline-flex; align-items: center; gap: 0.35rem; color: var(--accent); text-decoration: none; font-size: 0.88rem; font-weight: 600; }
+
+ .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; }
+
+ .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; }
+ .cta-band p { color: var(--text-dim); font-size: 0.9rem; margin: 0 0 1.2rem; }
+
+ @media (max-width: 720px) {
+ .pillars, .split { grid-template-columns: 1fr; }
+ }
+
+ /* ---------- security page ---------- */
+ .sec-head { padding: 1.8rem 0 0.6rem; text-align: center; }
+ .sec-head .kicker { display: inline-flex; align-items: center; gap: 0.45rem; font-family: var(--mono); font-size: 0.76rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ok-fg); background: var(--ok-bg); border: 1px solid rgba(123,214,168,0.3); padding: 0.3rem 0.7rem; border-radius: 999px; margin-bottom: 1.1rem; }
+ .sec-head h1 { font-size: clamp(1.6rem, 3.6vw, 2.1rem); margin: 0 auto 0.7rem; max-width: 620px; }
+ .sec-head p { color: var(--text-dim); max-width: 540px; margin: 0 auto; font-size: 0.95rem; }
+
+ .sec-section { margin: 2.6rem 0; padding-top: 2rem; border-top: 1px solid var(--border); }
+ .sec-section:first-of-type { border-top: none; }
+ .sec-section .tag { font-family: var(--mono); font-size: 0.72rem; color: var(--text-faint); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem; }
+ .sec-section h2 { font-size: 1.15rem; margin: 0 0 0.7rem; display: flex; align-items: center; gap: 0.55rem; }
+ .sec-section h2 .icon { width: 19px; height: 19px; color: var(--accent); }
+ .sec-section > p { color: var(--text-dim); font-size: 0.92rem; max-width: 640px; margin: 0 0 1.2rem; }
+ .sec-diagram { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 1.4rem; margin-bottom: 1rem; overflow-x: auto; }
+ .sec-diagram svg { display: block; margin: 0 auto; min-width: 380px; }
+ .sec-diagram .cap { text-align: center; color: var(--text-faint); font-size: 0.78rem; margin-top: 0.8rem; }
+ .sec-facts { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.7rem; margin-top: 1rem; }
+ .sec-fact { background: var(--surface-2); border: 1px solid var(--border); border-radius: 9px; padding: 0.85rem 1rem; font-size: 0.86rem; color: var(--text-dim); display: flex; gap: 0.6rem; }
+ .sec-fact .icon { width: 15px; height: 15px; color: var(--ok-fg); flex-shrink: 0; margin-top: 0.15rem; }
+ .sec-fact strong { color: var(--text); font-weight: 600; }
+ @media (max-width: 640px) { .sec-facts { grid-template-columns: 1fr; } }
+
/* ---------- repo page ---------- */
.gf-crumbs { font-family: var(--mono); font-size: 0.84rem; color: var(--text-dim); }
.gf-crumbs a { color: var(--text-dim); text-decoration: none; }
@@ -268,6 +385,7 @@ const shellSrc = `<!doctype html>
</style>
</head>
<body>
+{{.IconSprite}}
<header class="gf-nav">
<a href="/" class="gf-nav-brand">{{.BrandMark}}<span>gitfed</span></a>
<button class="gf-nav-burger" id="navBurger" aria-label="Toggle menu" aria-expanded="false" aria-controls="navPanel">
@@ -275,17 +393,23 @@ const shellSrc = `<!doctype html>
</button>
<div class="gf-nav-panel" id="navPanel">
<nav class="gf-nav-links">
- <a href="/"{{if eq .Active "home"}} class="active"{{end}}>Explore</a>
- {{if .LoggedIn}}<a href="/dashboard"{{if eq .Active "dashboard"}} class="active"{{end}}>Dashboard</a>{{end}}
+ <a href="/"{{if eq .Active "home"}} class="active"{{end}}>{{t .Lang "nav.home"}}</a>
+ <a href="/explore"{{if eq .Active "explore"}} class="active"{{end}}>{{t .Lang "nav.explore"}}</a>
+ <a href="/security"{{if eq .Active "security"}} class="active"{{end}}>{{t .Lang "nav.security"}}</a>
+ {{if .LoggedIn}}<a href="/dashboard"{{if eq .Active "dashboard"}} class="active"{{end}}>{{t .Lang "nav.dashboard"}}</a>{{end}}
</nav>
<form class="gf-nav-search" action="/search" method="get" role="search">
<div class="gf-nav-search-inner">
<svg width="14" height="14" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="9" cy="9" r="6.5"/><line x1="14" y1="14" x2="18" y2="18"/></svg>
- <input type="search" name="q" id="navSearch" placeholder="Search repos…" value="{{.SearchQuery}}" autocomplete="off">
+ <input type="search" name="q" id="navSearch" placeholder="{{t .Lang "nav.search_placeholder"}}" value="{{.SearchQuery}}" autocomplete="off">
<kbd>⌘K</kbd>
</div>
</form>
<div class="gf-nav-right">
+ <div class="gf-lang">
+ <a href="/lang/en?next={{.NextPath}}"{{if eq .Lang "en"}} class="active"{{end}}>EN</a>
+ <a href="/lang/fr?next={{.NextPath}}"{{if eq .Lang "fr"}} class="active"{{end}}>FR</a>
+ </div>
{{if .LoggedIn}}
<div class="gf-profile">
<button class="gf-profile-trigger" id="profileTrigger" aria-haspopup="true" aria-expanded="false" aria-controls="profileMenu">
@@ -294,26 +418,35 @@ const shellSrc = `<!doctype html>
<span class="chev">▾</span>
</button>
<div class="gf-profile-menu" id="profileMenu">
- <div class="who">Signed in as <strong>{{.Username}}</strong></div>
- <a href="/settings">⚙ Settings</a>
- {{if .IsAdmin}}<a href="/admin">🛡 Admin</a>{{end}}
+ <div class="who">{{t .Lang "nav.signed_in_as"}} <strong>{{.Username}}</strong></div>
+ <a href="/settings">{{icon "settings"}} {{t .Lang "nav.settings"}}</a>
+ {{if .IsAdmin}}<a href="/admin">{{icon "shield"}} {{t .Lang "nav.admin"}}</a>{{end}}
<div class="divider"></div>
- <form method="post" action="/logout"><button class="danger" type="submit">↪ Log out</button></form>
+ <form method="post" action="/logout"><button class="danger" type="submit">{{icon "logout"}} {{t .Lang "nav.logout"}}</button></form>
</div>
</div>
{{else}}
- <a href="/login">Log in</a>
+ <a href="/login">{{t .Lang "nav.login"}}</a>
{{end}}
</div>
</div>
</header>
-<main{{if .Wide}} class="wide"{{end}}>
+<main>
{{.Body}}
</main>
<footer>
- <a href="/changelog">gitfed {{.Version}}</a>
+ <a href="/changelog">{{t .Lang "footer.changelog" .Version}}</a>
</footer>
-<script>
+<script>` + shellScriptJS + `</script>
+</body>
+</html>`
+
+// shellScriptJS is the shell's inline script, kept as its own constant so its
+// SHA-256 can be pinned in the Content-Security-Policy (see securityHeaders):
+// with a strict script-src there is no 'unsafe-inline', so the browser only
+// runs this block if its hash matches. Any edit here changes that hash, which
+// is recomputed at init — the two never drift.
+const shellScriptJS = `
(function () {
var burger = document.getElementById('navBurger');
var panel = document.getElementById('navPanel');
@@ -346,9 +479,9 @@ const shellSrc = `<!doctype html>
var btn = e.target.closest('[data-copy]');
if (!btn || !navigator.clipboard) return;
navigator.clipboard.writeText(btn.getAttribute('data-copy')).then(function () {
- var orig = btn.textContent;
- btn.textContent = '✓';
- setTimeout(function () { btn.textContent = orig; }, 1200);
+ var orig = btn.innerHTML;
+ btn.innerHTML = '<svg class="icon" aria-hidden="true"><use href="#ic-check"/></svg>';
+ setTimeout(function () { btn.innerHTML = orig; }, 1200);
});
});
document.addEventListener('click', function (e) {
@@ -361,32 +494,36 @@ const shellSrc = `<!doctype html>
var panes = group.parentElement.querySelectorAll('.gf-tabpane');
panes.forEach(function (p) { p.classList.toggle('active', p.id === tab.getAttribute('data-tab')); });
});
+ // Delegated confirm for destructive forms, replacing inline onsubmit
+ // handlers (which a strict CSP script-src would block).
+ document.addEventListener('submit', function (e) {
+ var f = e.target.closest('form[data-confirm]');
+ if (f && !window.confirm(f.getAttribute('data-confirm'))) {
+ e.preventDefault();
+ }
+ });
})();
-</script>
-</body>
-</html>`
+`
-var shellTpl = template.Must(template.New("shell").Parse(shellSrc))
+// shellSrc is the full page shell: the static head/body markup (with template
+// actions) followed by the inline script, concatenated so the bytes between
+// <script>…</script> are exactly shellScriptJS and match the CSP hash.
+const shellSrc = shellHeadSrc
-func (s *server) render(w http.ResponseWriter, r *http.Request, title, active string, body template.HTML) {
- s.renderOpt(w, r, title, active, body, false)
-}
-
-// renderWide is render, but the page body isn't capped at the usual
-// reading-width column — for pages like the repo file browser where a
-// narrow column just wastes space on file/README content.
-func (s *server) renderWide(w http.ResponseWriter, r *http.Request, title, active string, body template.HTML) {
- s.renderOpt(w, r, title, active, body, true)
-}
+var shellTpl = newTpl("shell", shellSrc)
-func (s *server) renderOpt(w http.ResponseWriter, r *http.Request, title, active string, body template.HTML, wide bool) {
+func (s *server) render(w http.ResponseWriter, r *http.Request, title, active string, body template.HTML) {
sess, loggedIn := s.currentSession(r)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = shellTpl.Execute(w, struct {
- Title, Domain, Active, Username, Version, Initials, SearchQuery string
- LoggedIn, IsAdmin, Wide bool
- Body, BrandMark template.HTML
- }{title, s.domain, active, sess.Username, version.Version, initials(sess.Username), r.URL.Query().Get("q"), loggedIn, sess.IsAdmin, wide, body, template.HTML(brandMark)})
+ Title, Domain, Active, Username, Version, Initials, SearchQuery, Lang, NextPath string
+ LoggedIn, IsAdmin bool
+ Body, BrandMark, IconSprite template.HTML
+ }{
+ title, s.domain, active, sess.Username, version.Version, initials(sess.Username), r.URL.Query().Get("q"),
+ string(s.lang(r)), r.URL.RequestURI(),
+ loggedIn, sess.IsAdmin, body, template.HTML(brandMark), template.HTML(iconSprite),
+ })
}
// initials turns a username into a one-or-two-letter avatar label:
@@ -424,6 +561,14 @@ func flash(r *http.Request) template.HTML {
return template.HTML(`<div class="msg ` + class + `">` + template.HTMLEscapeString(msg) + `</div>`)
}
+// serverError logs the real error server-side and returns a generic message
+// to the client, so internal details (store paths, wrapped errors) never leak
+// into an HTTP response body.
+func (s *server) serverError(w http.ResponseWriter, r *http.Request, err error) {
+ log.Printf("gitfed-web: %s %s: %v", r.Method, r.URL.Path, err)
+ http.Error(w, i18n.T(s.lang(r), "common.server_error"), http.StatusInternalServerError)
+}
+
func redirectWithMsg(w http.ResponseWriter, r *http.Request, path, msg string, isErr bool) {
sep := "?"
if strings.Contains(path, "?") {
cmd/gitfed-web/routes.go
diff --git a/cmd/gitfed-web/routes.go b/cmd/gitfed-web/routes.go
index 62d5117..158c52f 100644
--- a/cmd/gitfed-web/routes.go
+++ b/cmd/gitfed-web/routes.go
@@ -4,7 +4,7 @@ import "net/http"
func (s *server) routes(mux *http.ServeMux) {
// Public — no login required.
- mux.HandleFunc("GET /{$}", s.handleHome)
+ mux.HandleFunc("GET /{$}", s.handleLanding)
mux.HandleFunc("GET /r/{repo...}", s.handleRepoView)
mux.HandleFunc("GET /repo-blob/{repo...}", s.handleRepoBlob)
mux.HandleFunc("GET /login", s.handleLoginForm)
@@ -12,6 +12,9 @@ func (s *server) routes(mux *http.ServeMux) {
mux.HandleFunc("POST /logout", s.handleLogout)
mux.HandleFunc("GET /changelog", s.handleChangelog)
mux.HandleFunc("GET /search", s.handleSearch)
+ mux.HandleFunc("GET /security", s.handleSecurity)
+ mux.HandleFunc("GET /explore", s.handleExplore)
+ mux.HandleFunc("GET /lang/{lang}", s.handleSetLang)
// Self-service — any logged-in user, scoped to their own stuff via
// CheckAccess inside the handlers.
cmd/gitfed-web/security_headers.go
diff --git a/cmd/gitfed-web/security_headers.go b/cmd/gitfed-web/security_headers.go
new file mode 100644
index 0000000..07fc25b
--- /dev/null
+++ b/cmd/gitfed-web/security_headers.go
@@ -0,0 +1,117 @@
+package main
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/base64"
+ "html/template"
+ "net/http"
+ "net/url"
+ "strings"
+)
+
+// scriptHash is the CSP source expression pinning the shell's inline script.
+// It's computed from the *rendered* shell rather than the raw shellScriptJS
+// constant: html/template rewrites some characters (e.g. "<") inside a
+// <script> element as breakout protection, so only the emitted bytes are what
+// the browser actually hashes. Deriving it here means it can never drift from
+// what's served.
+var scriptHash = "sha256-" + func() string {
+ sum := sha256.Sum256([]byte(renderedShellScript()))
+ return base64.StdEncoding.EncodeToString(sum[:])
+}()
+
+// renderedShellScript executes the shell template and returns the exact bytes
+// between <script> and </script> — the content the CSP hash must cover.
+func renderedShellScript() string {
+ var buf bytes.Buffer
+ _ = shellTpl.Execute(&buf, struct {
+ Title, Domain, Active, Username, Version, Initials, SearchQuery, Lang, NextPath string
+ LoggedIn, IsAdmin bool
+ Body, BrandMark, IconSprite template.HTML
+ }{Lang: "en"})
+ html := buf.String()
+ open := strings.Index(html, "<script>")
+ end := strings.Index(html, "</script>")
+ if open < 0 || end < 0 || end < open {
+ return ""
+ }
+ return html[open+len("<script>") : end]
+}
+
+// contentSecurityPolicy is deliberately strict: no 'unsafe-inline' for
+// scripts (the one inline block is allowed by its hash), everything else
+// same-origin, framing forbidden. style-src keeps 'unsafe-inline' because the
+// templates rely on inline style="" attributes throughout — those carry no
+// script and can't be nonce/hash-pinned, so this is the tightest workable
+// policy without a template-wide refactor. img-src allows data: for the
+// inline SVG favicons and markdown images.
+func contentSecurityPolicy() string {
+ return strings.Join([]string{
+ "default-src 'self'",
+ "script-src 'self' '" + scriptHash + "'",
+ "style-src 'self' 'unsafe-inline'",
+ // https: lets README images/badges load; still blocks http: and
+ // other schemes. data: covers the inline SVG favicons.
+ "img-src 'self' data: https:",
+ "font-src 'self'",
+ "connect-src 'self'",
+ "object-src 'none'",
+ "base-uri 'none'",
+ "frame-ancestors 'none'",
+ "form-action 'self'",
+ }, "; ")
+}
+
+// securityHeaders sets the standard hardening response headers on every
+// response. HSTS is safe to always emit: the app is only reachable over TLS
+// in production (the ingress terminates it) and browsers ignore the header on
+// plain-HTTP responses.
+func securityHeaders(next http.Handler) http.Handler {
+ csp := contentSecurityPolicy()
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ h := w.Header()
+ h.Set("Content-Security-Policy", csp)
+ h.Set("X-Frame-Options", "DENY")
+ h.Set("X-Content-Type-Options", "nosniff")
+ h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
+ h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
+ next.ServeHTTP(w, r)
+ })
+}
+
+// sameOriginPOST rejects state-changing requests whose Origin (or, failing
+// that, Referer) is not this same site. Combined with the SameSite=Lax
+// session cookie, this is defense-in-depth against CSRF that needs no
+// per-form token: browsers attach Origin to form POSTs, and a cross-site
+// attacker cannot forge it. A request that carries neither header on an
+// unsafe method is refused rather than trusted.
+func (s *server) sameOriginPOST(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace:
+ next.ServeHTTP(w, r)
+ return
+ }
+ if !sameOrigin(r) {
+ http.Error(w, "cross-origin request refused", http.StatusForbidden)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+func sameOrigin(r *http.Request) bool {
+ source := r.Header.Get("Origin")
+ if source == "" {
+ source = r.Header.Get("Referer")
+ }
+ if source == "" {
+ return false
+ }
+ u, err := url.Parse(source)
+ if err != nil || u.Host == "" {
+ return false
+ }
+ return strings.EqualFold(u.Host, r.Host)
+}
cmd/gitfed-web/security_headers_test.go
diff --git a/cmd/gitfed-web/security_headers_test.go b/cmd/gitfed-web/security_headers_test.go
new file mode 100644
index 0000000..7be49ae
--- /dev/null
+++ b/cmd/gitfed-web/security_headers_test.go
@@ -0,0 +1,78 @@
+package main
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/base64"
+ "html/template"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+// TestCSPScriptHashMatchesRenderedScript renders the shell and checks that the
+// CSP hash we advertise equals the hash of the script actually served — if a
+// future edit to the shell changes either the script or how it's emitted, this
+// fails instead of silently breaking every page under the strict policy.
+func TestCSPScriptHashMatchesRenderedScript(t *testing.T) {
+ var buf bytes.Buffer
+ err := shellTpl.Execute(&buf, struct {
+ Title, Domain, Active, Username, Version, Initials, SearchQuery, Lang, NextPath string
+ LoggedIn, IsAdmin bool
+ Body, BrandMark, IconSprite template.HTML
+ }{Title: "t", Lang: "en"})
+ if err != nil {
+ t.Fatalf("execute shell: %v", err)
+ }
+
+ html := buf.String()
+ open := strings.Index(html, "<script>")
+ close := strings.Index(html, "</script>")
+ if open < 0 || close < 0 || close < open {
+ t.Fatalf("no <script>…</script> in rendered shell")
+ }
+ served := html[open+len("<script>") : close]
+
+ sum := sha256.Sum256([]byte(served))
+ want := "sha256-" + base64.StdEncoding.EncodeToString(sum[:])
+ if want != scriptHash {
+ t.Fatalf("CSP script hash mismatch:\n advertised %s\n served %s", scriptHash, want)
+ }
+}
+
+func TestSameOriginPOST(t *testing.T) {
+ s := &server{}
+ handler := s.sameOriginPOST(nil)
+ _ = handler // constructed to ensure it wraps without panicking
+
+ cases := []struct {
+ name string
+ method string
+ origin string
+ referer string
+ host string
+ wantOrigin bool
+ }{
+ {"get always passes", "GET", "", "", "example.com", true},
+ {"matching origin", "POST", "https://example.com", "", "example.com", true},
+ {"mismatched origin", "POST", "https://evil.com", "", "example.com", false},
+ {"referer fallback match", "POST", "", "https://example.com/login", "example.com", true},
+ {"no headers refused", "POST", "", "", "example.com", false},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ r := httptest.NewRequest(c.method, "http://example.com/x", nil)
+ r.Host = c.host
+ if c.origin != "" {
+ r.Header.Set("Origin", c.origin)
+ }
+ if c.referer != "" {
+ r.Header.Set("Referer", c.referer)
+ }
+ got := c.method == "GET" || sameOrigin(r)
+ if got != c.wantOrigin {
+ t.Fatalf("got allowed=%v, want %v", got, c.wantOrigin)
+ }
+ })
+ }
+}
cmd/gitfed-web/session.go
diff --git a/cmd/gitfed-web/session.go b/cmd/gitfed-web/session.go
index 07c68ad..8cff453 100644
--- a/cmd/gitfed-web/session.go
+++ b/cmd/gitfed-web/session.go
@@ -18,7 +18,7 @@ func setSessionCookie(w http.ResponseWriter, token string) {
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
- Expires: time.Now().Add(30 * 24 * time.Hour),
+ Expires: time.Now().Add(7 * 24 * time.Hour), // matches admin.sessionTTL
})
}
deploy/k8s/configmap.yaml
diff --git a/deploy/k8s/configmap.yaml b/deploy/k8s/configmap.yaml
index 3f25480..1981330 100644
--- a/deploy/k8s/configmap.yaml
+++ b/deploy/k8s/configmap.yaml
@@ -16,6 +16,6 @@ data:
"listen_http": ":8443",
"contact": "bastien.marques@outlook.com",
"trust_policy": "whitelist",
- "cert_ttl_hours": 48,
+ "cert_ttl_hours": 24,
"insecure_federation": false
}
deploy/k8s/deployment.yaml
diff --git a/deploy/k8s/deployment.yaml b/deploy/k8s/deployment.yaml
index bdc11dc..3a311ee 100644
--- a/deploy/k8s/deployment.yaml
+++ b/deploy/k8s/deployment.yaml
@@ -59,10 +59,19 @@ spec:
- name: config
mountPath: /etc/gitfed
readOnly: true
+ - name: tmp
+ mountPath: /tmp
+ - name: home
+ mountPath: /home/gitfed
securityContext:
runAsNonRoot: true
runAsUser: 1000
allowPrivilegeEscalation: false
+ readOnlyRootFilesystem: true
+ capabilities:
+ drop: [ALL]
+ seccompProfile:
+ type: RuntimeDefault
resources:
requests: {cpu: 20m, memory: 32Mi}
limits: {cpu: 300m, memory: 256Mi}
@@ -92,10 +101,19 @@ spec:
- name: config
mountPath: /etc/gitfed
readOnly: true
+ - name: web-tmp
+ mountPath: /tmp
+ - name: web-home
+ mountPath: /home/gitfed
securityContext:
runAsNonRoot: true
runAsUser: 1000
allowPrivilegeEscalation: false
+ readOnlyRootFilesystem: true
+ capabilities:
+ drop: [ALL]
+ seccompProfile:
+ type: RuntimeDefault
resources:
requests: {cpu: 10m, memory: 32Mi}
limits: {cpu: 200m, memory: 128Mi}
@@ -110,3 +128,14 @@ spec:
- name: config
configMap:
name: gitfed-config
+ # Writable scratch for the read-only root filesystem: git and the
+ # runtime need a writable /tmp and $HOME even though the rootfs is
+ # locked down. Per-container so the two never share scratch state.
+ - name: tmp
+ emptyDir: {}
+ - name: home
+ emptyDir: {}
+ - name: web-tmp
+ emptyDir: {}
+ - name: web-home
+ emptyDir: {}
deploy/k8s/networkpolicy.yaml
diff --git a/deploy/k8s/networkpolicy.yaml b/deploy/k8s/networkpolicy.yaml
new file mode 100644
index 0000000..31cc3b8
--- /dev/null
+++ b/deploy/k8s/networkpolicy.yaml
@@ -0,0 +1,45 @@
+# Egress hardening: gitfed only ever needs to reach the public internet (to
+# fetch other instances' /.well-known/gitfed.json during federation) plus
+# cluster DNS. This policy blocks outbound traffic to private / internal
+# ranges, so even if the application-level anti-SSRF guard (see
+# internal/federation/wellknown.go) were bypassed, a crafted federation target
+# still couldn't reach cluster-internal or link-local (cloud-metadata)
+# addresses. Ingress is left to the ingress controller / Service.
+#
+# NOTE: requires a CNI that enforces NetworkPolicy (Cilium, Calico, ...).
+# k3s' default flannel does NOT enforce it — deploy a policy-capable CNI or
+# treat this as documentation of intent.
+apiVersion: networking.k8s.io/v1
+kind: NetworkPolicy
+metadata:
+ name: gitfed-egress
+ namespace: gitfed
+spec:
+ podSelector:
+ matchLabels:
+ app: gitfed
+ policyTypes: [Egress]
+ egress:
+ # Cluster DNS resolution.
+ - to:
+ - namespaceSelector: {}
+ ports:
+ - protocol: UDP
+ port: 53
+ - protocol: TCP
+ port: 53
+ # Public internet on 443 (federation well-known fetch), excluding all
+ # private, loopback, link-local and CGNAT ranges.
+ - to:
+ - ipBlock:
+ cidr: 0.0.0.0/0
+ except:
+ - 10.0.0.0/8
+ - 172.16.0.0/12
+ - 192.168.0.0/16
+ - 169.254.0.0/16
+ - 127.0.0.0/8
+ - 100.64.0.0/10
+ ports:
+ - protocol: TCP
+ port: 443
docs/security/AUDIT.md
diff --git a/docs/security/AUDIT.md b/docs/security/AUDIT.md
new file mode 100644
index 0000000..5ebe574
--- /dev/null
+++ b/docs/security/AUDIT.md
@@ -0,0 +1,214 @@
+# Rapport d'audit de sécurité — gitfed
+
+- **Date** : 2026-07-28
+- **Périmètre** : audit défensif complet avant mise en production, avec focus sur l'authentification et la certification (fédération / CA / SSH).
+- **Cible** : dépôt `gitfed` (branche `main`), commit `523f049` (Deploy v0.6.0).
+- **Nature** : revue de code statique + inspection de la configuration de déploiement (Docker / Kubernetes).
+
+---
+
+## 1. Synthèse
+
+Le cœur de sécurité du projet est **sain** :
+
+- **Aucune backdoor** détectée.
+- La chaîne de confiance fédérée est **cryptographiquement vérifiée** : `CertChecker.CheckCert` vérifie la signature du certificat (`golang.org/x/crypto/ssh/certs.go:456-459`), et le code **épingle la clé d'autorité** avant vérification (`internal/ssh/server.go:148-170`).
+- **Aucun secret commité** : le dossier `demo/` (clés CA, host keys, DB) est bien couvert par `.gitignore` et non suivi par git.
+- **Pas d'injection shell** : git est invoqué via `exec.Command` sans passer par un shell (`internal/gitexec/gitexec.go`).
+- **Pas de XSS stocké** : `html/template` échappe automatiquement, et goldmark (sans `WithUnsafe`) filtre le HTML brut **et** les URLs dangereuses (`javascript:`, `data:`, `vbscript:`) — vérifié dans `goldmark@v1.8.4/renderer/html/html.go:518`.
+- Mots de passe hachés en **bcrypt**, jamais renvoyés par l'API (bucket séparé `auth`).
+
+Les vulnérabilités identifiées sont des **durcissements classiques d'avant-production**, pas des failles critiques exploitables à distance sans conditions. La plus prioritaire est l'absence de limitation des tentatives de connexion web.
+
+### Tableau récapitulatif
+
+| ID | Sévérité | Titre | Fichier principal |
+|-----|----------|-------------------------------------------------------------|-------------------|
+| H1 | 🔴 High | Aucune limitation des tentatives de login web | `cmd/gitfed-web/handlers_auth.go:41` |
+| M1 | 🟠 Medium | SSRF via la découverte de fédération | `internal/federation/wellknown.go:47` |
+| M2 | 🟠 Medium | Pas de révocation de certificat | `internal/ssh/server.go:130` |
+| M3 | 🟠 Medium | En-têtes HTTP de sécurité absents | `cmd/gitfed-web/render.go` |
+| M4 | 🟠 Medium | `gitfed-renew-cert` : host key non vérifié par défaut | `cmd/gitfed-renew-cert/main.go:84` |
+| M5 | 🟠 Medium | Timeouts HTTP absents + corps de réponse non borné | `cmd/gitfed-web/main.go:55`, `internal/federation/wellknown.go:53` |
+| L1 | 🟡 Low | Énumération d'utilisateurs par timing | `internal/admin/admin.go:332` |
+| L2 | 🟡 Low | Fuite de messages d'erreur internes vers l'utilisateur | multiples handlers web |
+| L3 | 🟡 Low | Session : TTL 30 j, pas de rotation, pas de purge | `internal/admin/admin.go:353`, `internal/store/sessions.go` |
+| L4 | 🟡 Low | bcrypt coût 10 ; politique de mot de passe minimale | `internal/admin/admin.go:312-322` |
+| L5 | 🟡 Low | Squat de namespace de repo | `cmd/gitfed-web/handlers_dashboard.go:88` |
+| L6 | 🟡 Low | Pas de quota repos/utilisateurs (DoS disque) | `internal/admin/admin.go:186` |
+| L7 | 🟡 Low | Durcissement conteneur K8s incomplet | `deploy/k8s/deployment.yaml` |
+
+---
+
+## 2. Détail des vulnérabilités
+
+### 🔴 H1 — Aucune limitation des tentatives de connexion web
+
+**Fichier** : `cmd/gitfed-web/handlers_auth.go:41` (`handleLogin`)
+
+Le formulaire de login est exposé sur Internet (ingress `/`) sans **rate-limiting**, sans **lockout de compte**, sans **délai** ni captcha. bcrypt (coût 10) ralentit chaque essai, mais un brute-force en ligne ciblé reste réalisable.
+
+**Aggravant architectural** : le socket admin (`/data/admin.sock`) est un accès « god-mode » **sans authentification** (protégé uniquement par le mode fichier `0600`). Le conteneur `web` détient donc toute la capacité admin de l'instance ; les gardes de route (`requireLogin` / `requireAdmin`) sont la **seule** barrière entre un visiteur anonyme et le contrôle total. Toute faiblesse du login en amplifie l'impact.
+
+**Impact** : compromission de compte (y compris admin) par force brute / bourrage d'identifiants.
+
+---
+
+### 🟠 M1 — SSRF via la découverte de fédération
+
+**Fichiers** : `internal/federation/wellknown.go:47` (`Fetch`), `internal/admin/admin.go:291` (`GrantCollaborator`), `internal/federation/resolver.go:63` (`EnsureTrust`)
+
+`Fetch(domain)` construit `https://<domain>/.well-known/gitfed.json` et effectue une requête HTTP sortante. Le `domain` provient de `GrantCollaborator`, exposé via `POST /repo-grant/{repo}` à **tout propriétaire de repo** — pas uniquement à un admin d'instance (la garde est `canAdminister` sur le repo, `handlers_repo.go:441`).
+
+Un utilisateur authentifié peut donc ajouter un collaborateur du type `x@169.254.169.254` ou `x@10.0.0.5:6379` et déclencher une requête vers une IP interne ou l'endpoint de métadonnées cloud.
+
+**Limitations pour l'attaquant** :
+- SSRF « aveugle » : seul le succès/échec fuit (via le log d'audit et le timing) ; la réponse est validée (`doc.Domain == domain` + `ca_public_key` requis) et n'est pas réfléchie.
+- Rate-limité à 10 nouvelles découvertes/minute (`resolver.go:33`).
+
+**Manques** : aucune validation du format de domaine, aucun blocage des IP littérales / plages privées / link-local / metadata, pas de protection anti-DNS-rebinding.
+
+**Impact** : scan de ports interne, sondage de services internes, atteinte potentielle aux métadonnées cloud.
+
+---
+
+### 🟠 M2 — Pas de révocation de certificat
+
+**Fichiers** : `internal/ssh/server.go:130` (`checkCert`), `internal/ca/ca.go:101` (`IssueUserCert`)
+
+Un certificat émis reste valide jusqu'à son expiration (**48 h** par défaut, `config.go:39`) même après :
+- la suppression de l'utilisateur (`DeleteUser`),
+- le retrait de la clé publique certifiée.
+
+`checkCert` ne valide que la signature CA et le format du principal — il ne vérifie **jamais** que l'utilisateur ou la clé existent encore côté serveur. `CertChecker` est instancié sans `IsRevoked` (`server.go:167`), donc aucune KRL/CRL n'est consultée.
+
+À l'usage, un utilisateur supprimé conserve, tant que son cert est valide, l'accès en lecture aux repos publics (l'autorisation par repo via `acl.Check` limite le reste). Mais la **révocation immédiate** d'un compte compromis est impossible.
+
+**Impact** : fenêtre de révocation jusqu'à 48 h ; pas de kill-switch pour une clé/compte compromis.
+
+---
+
+### 🟠 M3 — En-têtes HTTP de sécurité absents
+
+**Fichier** : `cmd/gitfed-web/render.go`, ensemble du serveur web
+
+Aucun en-tête de sécurité n'est émis :
+- `Content-Security-Policy` (pertinent : le shell contient un `<script>` inline, `render.go:439`),
+- `X-Frame-Options: DENY` / `frame-ancestors` → **clickjacking** possible sur les formulaires admin (make-admin, delete-user, approve-domain),
+- `X-Content-Type-Options: nosniff`,
+- `Referrer-Policy`,
+- `Strict-Transport-Security` (HSTS).
+
+**Impact** : clickjacking, absence de défense en profondeur contre l'exécution de contenu injecté, fuite de referrer.
+
+---
+
+### 🟠 M4 — `gitfed-renew-cert` : host key non vérifié par défaut
+
+**Fichier** : `cmd/gitfed-renew-cert/main.go:84`
+
+Sans l'option `-host-key`, l'outil utilise `gossh.InsecureIgnoreHostKey()`. Il émet un avertissement mais poursuit la connexion. Un attaquant en position de MITM peut intercepter le renouvellement de certificat.
+
+**Impact** : interception du canal de renouvellement ; usurpation de l'instance d'origine. Outil côté client, mais distribué avec le projet et documenté comme point d'entrée officiel.
+
+---
+
+### 🟠 M5 — Timeouts HTTP absents + corps de réponse non borné
+
+**Fichiers** : `cmd/gitfed-web/main.go:55`, `cmd/gitfed-server/main.go:127`, `internal/federation/wellknown.go:53`
+
+- Les serveurs web et well-known utilisent `http.ListenAndServe` sans `ReadHeaderTimeout` / `ReadTimeout` / `WriteTimeout` / `IdleTimeout` → exposition **Slowloris**.
+- `Fetch` décode `resp.Body` via `json.NewDecoder` sans `io.LimitReader` → un pair distant malveillant peut renvoyer un corps arbitrairement grand (DoS mémoire pendant la découverte).
+
+**Impact** : épuisement de connexions / mémoire.
+
+---
+
+### 🟡 L1 — Énumération d'utilisateurs par timing
+
+**Fichier** : `internal/admin/admin.go:332` (`VerifyPassword`)
+
+Quand l'utilisateur ou le hash est absent, la fonction retourne **sans** appeler bcrypt. La différence de temps de réponse permet de distinguer un utilisateur existant d'un inexistant.
+
+**Correctif** : comparer systématiquement contre un hash bcrypt factice.
+
+---
+
+### 🟡 L2 — Fuite de messages d'erreur internes
+
+**Fichiers** : `handlers_auth.go:49,60`, `handlers_settings.go`, handlers admin
+
+Plusieurs chemins renvoient `err.Error()` brut à l'utilisateur, pouvant divulguer des détails internes (chemins, structure du store).
+
+**Correctif** : messages génériques côté client, détail journalisé côté serveur.
+
+---
+
+### 🟡 L3 — Gestion de session
+
+**Fichiers** : `internal/admin/admin.go:353` (`sessionTTL = 30 j`), `internal/store/sessions.go`
+
+- TTL de **30 jours**, sans rotation à l'élévation de privilège, sans expiration d'inactivité.
+- Les sessions expirées ne sont supprimées que **paresseusement** (à l'accès), pas purgées — croissance non bornée du bucket.
+
+---
+
+### 🟡 L4 — Politique de mot de passe / coût bcrypt
+
+**Fichier** : `internal/admin/admin.go:312-322`
+
+- bcrypt `DefaultCost` (10) ; recommandation 2026 : **12**.
+- Longueur minimale 8, sans vérification de mot de passe compromis.
+
+---
+
+### 🟡 L5 — Squat de namespace de repo
+
+**Fichier** : `cmd/gitfed-web/handlers_dashboard.go:88` (`handleCreateRepo`)
+
+Le nom du repo est libre : un utilisateur `alice` peut créer `bob/x`. L'owner reste `alice@domain` (pas d'usurpation d'identité), mais le nom est trompeur. Le path traversal est bien bloqué (`ResolvePath` rejette `..`).
+
+---
+
+### 🟡 L6 — Pas de quota
+
+**Fichier** : `internal/admin/admin.go:186` (`CreateRepo`)
+
+Aucun plafond sur le nombre de repos/utilisateurs. Chaque repo déclenche un `git init --bare` → DoS disque possible par un utilisateur authentifié.
+
+---
+
+### 🟡 L7 — Durcissement conteneur K8s incomplet
+
+**Fichier** : `deploy/k8s/deployment.yaml`
+
+Bon socle (non-root, `allowPrivilegeEscalation: false`, limites de ressources). Manquent :
+- `readOnlyRootFilesystem: true`,
+- `capabilities: { drop: [ALL] }`,
+- `seccompProfile: { type: RuntimeDefault }`,
+- une `NetworkPolicy` restreignant les flux sortants (renforce M1).
+
+---
+
+## 3. Contrôles vérifiés et jugés sains
+
+| Domaine | Constat |
+|---------|---------|
+| Certificats | Signature vérifiée + autorité épinglée (`server.go:163-170`) |
+| Fédération | Réponse well-known validée (domaine + clé CA présente) |
+| Injection | git via `exec.Command` sans shell ; noms de repo validés par regex + rejet `..` |
+| Path traversal | Chemins de fichiers bornés par git (`git show ref:path`) |
+| XSS | `html/template` + goldmark filtre HTML brut et URLs dangereuses |
+| Open redirect | `sanitizeNext` restreint `next` aux chemins locaux |
+| CSRF | Cookies `HttpOnly` + `Secure` + `SameSite=Lax` (couvre le POST cross-site) |
+| Secrets au repos | Clés CA / host / DB / socket en `0600` ; `demo/` gitignoré |
+| Conteneurs | Exécution non-root, privilège non escaladable |
+| TLS | Terminaison Traefik + cert-manager (Let's Encrypt) en prod |
+
+> **Prérequis de déploiement à documenter** : le endpoint well-known est servi en **HTTP clair** sur `:8443` et dépend entièrement de la terminaison TLS de l'ingress. En Kubernetes c'est correct (`insecure_federation: false`), mais toute exécution hors de ce chemin ferait transiter la clé publique CA en clair (MITM sur la découverte de fédération). Ne jamais exposer `:8443` directement sur Internet sans TLS devant.
+
+---
+
+## 4. Note sur le CSRF
+
+L'absence de jetons CSRF explicites est **largement compensée** par `SameSite=Lax` : les navigateurs n'envoient pas le cookie de session sur une requête POST cross-site, ce qui protège tous les endpoints de mutation (qui sont en POST). Le seul endpoint mutateur en GET est `/lang/{lang}` (changement de langue, sans impact sécurité). Le risque résiduel est donc **faible**, mais l'ajout de jetons CSRF reste recommandé en défense en profondeur pour les actions admin (voir plan, tâche 8).
docs/security/FIX_PLAN.md
diff --git a/docs/security/FIX_PLAN.md b/docs/security/FIX_PLAN.md
new file mode 100644
index 0000000..4914817
--- /dev/null
+++ b/docs/security/FIX_PLAN.md
@@ -0,0 +1,192 @@
+# Plan d'implémentation des correctifs de sécurité — gitfed
+
+Référence : `docs/security/AUDIT.md` (audit du 2026-07-28).
+
+> **État : ✅ intégralement implémenté (2026-07-28).** Les 12 tâches (T1–T12)
+> sont livrées. `go build ./...`, `go vet ./...` et `go test ./...` passent.
+> Tests ajoutés : hash CSP vs script rendu, same-origin POST, validation de
+> domaine anti-SSRF, blocage d'IP privées, révocation (store + admin).
+>
+> | Tâche | État | Fichiers clés |
+> |-------|------|---------------|
+> | T1 rate-limit login | ✅ | `cmd/gitfed-web/ratelimit.go`, `handlers_auth.go`, `main.go` |
+> | T2 anti-SSRF fédération | ✅ | `internal/federation/wellknown.go` (+ `ssrf_test.go`) |
+> | T3 en-têtes + CSP | ✅ | `cmd/gitfed-web/security_headers.go`, `render.go` |
+> | T4 timeouts + corps borné | ✅ | `cmd/gitfed-web/main.go`, `cmd/gitfed-server/main.go`, `wellknown.go` |
+> | T5 révocation de cert | ✅ | `internal/store/revocations.go`, `ssh/server.go`, `admin/admin.go`, `config.go` |
+> | T6 host key obligatoire | ✅ | `cmd/gitfed-renew-cert/main.go` |
+> | T7 bcrypt temps constant | ✅ | `internal/admin/admin.go` (dummyHash) |
+> | T8 erreurs génériques + CSRF | ✅ | `render.go` (serverError), `security_headers.go` (sameOriginPOST) |
+> | T9 sessions | ✅ | `internal/store/sessions.go`, `admin.go`, `cmd/gitfed-server/main.go`, `session.go` |
+> | T10 bcrypt coût 12 | ✅ | `internal/admin/admin.go` |
+> | T11 namespace + quota | ✅ | `cmd/gitfed-web/handlers_dashboard.go` |
+> | T12 durcissement K8s | ✅ | `deploy/k8s/deployment.yaml`, `networkpolicy.yaml`, `configmap.yaml` |
+>
+> **Choix de conception à noter :**
+> - **CSRF (T8)** : implémenté par vérification d'origine (`Origin`/`Referer`)
+> sur les méthodes non-sûres plutôt que par jetons par formulaire — robuste,
+> uniforme, sans toucher les 15 formulaires. En complément de `SameSite=Lax`.
+> - **CSP (T3)** : le script inline est autorisé par son **hash SHA-256**
+> (calculé sur le script *rendu*, pas la constante brute, car html/template
+> réécrit `<` dans un `<script>`). `style-src 'unsafe-inline'` reste requis
+> pour les attributs `style="..."` omniprésents. `img-src` autorise `https:`
+> pour ne pas casser les images de README.
+> - **SSRF (T2)** : double barrière — validation du format de domaine + blocage
+> des IP non-publiques **au moment du dial** (avec pinning de l'IP validée,
+> anti-DNS-rebinding). Désactivée uniquement en mode `insecure_federation`
+> (dev/loopback).
+> - **NetworkPolicy (T12)** : nécessite un CNI qui l'applique (Cilium/Calico) ;
+> flannel de k3s ne l'applique pas — sinon elle vaut documentation d'intention.
+>
+> Ce qui suit est le plan d'origine, conservé pour la traçabilité.
+
+Le plan est découpé en **3 lots** par priorité décroissante. Chaque tâche indique le fichier, l'approche, le test de validation et une estimation d'effort (S = < 1 h, M = 1–3 h, L = > 3 h).
+
+Ordre de traitement recommandé : **Lot 1 en entier** (bloquant prod) → **Lot 2** → **Lot 3**.
+
+---
+
+## Lot 1 — Bloquant avant mise en production
+
+### T1 — Rate-limiting du login web *(H1 — effort M)*
+
+**Fichiers** : `cmd/gitfed-web/handlers_auth.go`, nouveau `cmd/gitfed-web/ratelimit.go`
+
+- Implémenter un limiteur in-memory (map protégée par mutex, ou `golang.org/x/time/rate` par clé) indexé sur **(IP source, nom d'utilisateur)**.
+- Politique : back-off progressif après N échecs (ex. 5 essais / 15 min par compte, 20 / 15 min par IP), avec réponse générique et délai constant.
+- Extraire l'IP réelle derrière l'ingress via `X-Forwarded-For` (en ne faisant confiance qu'au dernier proxy) ou `X-Real-IP`.
+- Journaliser chaque échec dans l'audit (`AppendAudit`, action `web-login`, `Allowed:false`).
+- Purge périodique des entrées expirées.
+
+**Validation** : test unitaire simulant N+1 tentatives → la (N+1)ᵉ est refusée ; une IP différente n'est pas affectée après le lockout d'un compte.
+
+---
+
+### T2 — Anti-SSRF sur la découverte de fédération *(M1 — effort M)*
+
+**Fichiers** : `internal/federation/wellknown.go`, `internal/admin/admin.go`
+
+1. **Valider le domaine** avant tout accès réseau (`GrantCollaborator` et `Fetch`) :
+ - hostname RFC-conforme uniquement ; rejeter les IP littérales ; refuser un port autre que le port implicite HTTPS (ou n'autoriser que le 443).
+2. **Bloquer les cibles internes** via un `net.Dialer.Control` (ou `DialContext` custom) sur le `http.Client` : après résolution, refuser toute connexion vers une IP privée / loopback / link-local / metadata (`127.0.0.0/8`, `10/8`, `172.16/12`, `192.168/16`, `169.254/16`, `::1`, `fc00::/7`, `fe80::/10`, `100.64/10`). Contrôle appliqué **sur l'IP effectivement composée** → protège aussi du DNS-rebinding.
+3. **Restreindre la surface** : n'autoriser la découverte d'un **nouveau** domaine que pour un admin d'instance (les propriétaires de repo non-admin ne déclenchent pas de fetch sortant), ou passer toute nouvelle découverte par la file d'approbation admin existante.
+
+**Validation** : tests table-driven — `169.254.169.254`, `localhost`, `10.0.0.1`, `[::1]`, domaine avec port arbitraire → tous rejetés ; domaine public valide → autorisé. Test anti-rebinding : nom résolvant vers une IP privée → refusé au dial.
+
+---
+
+### T3 — En-têtes HTTP de sécurité *(M3 — effort S)*
+
+**Fichiers** : nouveau `cmd/gitfed-web/security_headers.go`, `cmd/gitfed-web/main.go`
+
+- Middleware enveloppant le mux :
+ - `Content-Security-Policy` stricte. Le `<script>` inline du shell impose soit un **nonce** par requête (injecté dans le template et la CSP), soit un **hash** du script. Préférer le nonce.
+ - `X-Frame-Options: DENY` (+ `frame-ancestors 'none'` dans la CSP).
+ - `X-Content-Type-Options: nosniff`.
+ - `Referrer-Policy: strict-origin-when-cross-origin`.
+ - `Strict-Transport-Security: max-age=31536000; includeSubDomains` (émis seulement derrière TLS).
+
+**Validation** : test HTTP vérifiant la présence de chaque en-tête ; contrôle manuel via `curl -I` ; page fonctionne sans erreur CSP en console.
+
+---
+
+### T4 — Timeouts serveur + corps de réponse borné *(M5 — effort S)*
+
+**Fichiers** : `cmd/gitfed-web/main.go`, `cmd/gitfed-server/main.go`, `internal/federation/wellknown.go`
+
+- Remplacer les deux `http.ListenAndServe(...)` par un `&http.Server{ ReadHeaderTimeout: 5s, ReadTimeout: 15s, WriteTimeout: 30s, IdleTimeout: 60s, Handler: mux }`.
+- Dans `Fetch`, envelopper le corps : `io.LimitReader(resp.Body, 1<<20)` (1 Mo) avant `json.NewDecoder`.
+
+**Validation** : `go build` ; test qu'un corps > 1 Mo est tronqué/rejeté proprement.
+
+---
+
+## Lot 2 — Renforcement
+
+### T5 — Révocation de certificat *(M2 — effort L)*
+
+**Fichiers** : `internal/store/` (nouveau bucket `revocations`), `internal/ssh/server.go`, `internal/admin/admin.go`
+
+- Ajouter une liste de révocation persistée (par `KeyId` de cert et/ou par empreinte de clé publique + horodatage).
+- Renseigner `CertChecker.IsRevoked` dans `checkCert` pour la consulter.
+- Révoquer automatiquement à `DeleteUser` et `RemoveUserKey` (invalider les certs portant la clé retirée).
+- Réduire le TTL par défaut à **12–24 h** (`config.go` / `InstanceMeta`).
+- À défaut d'implémenter la KRL immédiatement : **documenter** explicitement la fenêtre de 48 h dans le README d'exploitation.
+
+**Validation** : émettre un cert, révoquer, vérifier que l'auth SSH est refusée ; vérifier qu'un cert non révoqué passe toujours.
+
+---
+
+### T6 — `gitfed-renew-cert` : host key obligatoire *(M4 — effort S)*
+
+**Fichier** : `cmd/gitfed-renew-cert/main.go`
+
+- Faire échouer si `-host-key` est absent (au lieu d'`InsecureIgnoreHostKey`), **ou** implémenter un TOFU persistant (known_hosts local) avec avertissement à la première connexion seulement.
+- Documenter la récupération de l'empreinte du host key de l'instance.
+
+**Validation** : sans `-host-key` → refus ; avec la bonne clé → succès ; avec une mauvaise clé → refus.
+
+---
+
+### T7 — Comparaison bcrypt en temps constant *(L1 — effort S)*
+
+**Fichier** : `internal/admin/admin.go` (`VerifyPassword`)
+
+- Quand l'utilisateur ou le hash est absent, comparer le mot de passe fourni contre un **hash bcrypt factice** préchargé, puis retourner l'échec — pour égaliser le temps de réponse.
+
+**Validation** : test comparant grossièrement les temps de réponse utilisateur existant vs inexistant.
+
+---
+
+### T8 — Messages d'erreur génériques + jetons CSRF (défense en profondeur) *(L2 — effort M)*
+
+**Fichiers** : handlers web, nouveau helper CSRF
+
+- Mapper les erreurs internes vers des messages utilisateurs génériques ; journaliser le détail côté serveur.
+- Ajouter un jeton CSRF (double-submit cookie ou jeton lié à la session) sur les formulaires POST, en priorité les actions admin (make-admin, delete-user, approve-domain).
+
+**Validation** : POST sans jeton valide → rejeté ; parcours normal → OK.
+
+---
+
+## Lot 3 — Défense en profondeur
+
+### T9 — Durcissement des sessions *(L3 — effort S)*
+
+**Fichiers** : `internal/admin/admin.go`, `internal/store/sessions.go`
+
+- Réduire `sessionTTL` (ex. 7 j) ; rotation du token à l'élévation de privilège.
+- Tâche de purge périodique des sessions expirées.
+
+### T10 — Politique de mot de passe *(L4 — effort S)*
+
+**Fichier** : `internal/admin/admin.go`
+
+- Passer bcrypt au coût **12**.
+- Optionnel : contrôle contre une liste de mots de passe communs.
+
+### T11 — Namespace & quotas *(L5/L6 — effort M)*
+
+**Fichiers** : `internal/admin/admin.go`, `cmd/gitfed-web/handlers_dashboard.go`
+
+- Préfixer/valider le nom de repo par l'utilisateur créateur, ou réserver le namespace.
+- Introduire un quota configurable de repos par utilisateur.
+
+### T12 — Durcissement K8s *(L7 — effort S)*
+
+**Fichier** : `deploy/k8s/deployment.yaml`, nouveau `deploy/k8s/networkpolicy.yaml`
+
+- Ajouter `readOnlyRootFilesystem: true` (+ `emptyDir` pour les chemins temporaires si besoin), `capabilities: { drop: [ALL] }`, `seccompProfile: { type: RuntimeDefault }`.
+- `NetworkPolicy` limitant l'egress (renforce T2 contre la SSRF).
+
+---
+
+## Séquencement suggéré
+
+```
+Sprint 1 (prod-ready) : T1 → T2 → T3 → T4
+Sprint 2 (renfort) : T5 → T6 → T7 → T8
+Sprint 3 (hardening) : T9 → T10 → T11 → T12
+```
+
+Chaque lot est indépendant et livrable séparément. Aucune tâche n'introduit de changement de rupture d'API. Recommandation : une PR par tâche (ou par lot pour les tâches S), avec les tests de validation associés, et exécution de `go test ./...` avant merge.
internal/admin/admin.go
diff --git a/internal/admin/admin.go b/internal/admin/admin.go
index fb954ca..ce3eb1c 100644
--- a/internal/admin/admin.go
+++ b/internal/admin/admin.go
@@ -76,13 +76,22 @@ func New(st *store.Store, resolver *federation.Resolver, domain, reposDir string
}
// CreateUser registers a new local user with an initial SSH public key
-// (authorized_keys format).
+// (authorized_keys format). It clears any lingering revocation for the
+// principal or that key, so re-creating a previously-deleted user (or
+// re-adding a removed key) restores certificate access.
func (a *Admin) CreateUser(username, pubKeyAuthorized string) error {
key, err := canonicalAuthorizedKey(pubKeyAuthorized)
if err != nil {
return err
}
- return a.Store.CreateUser(store.User{Username: username, PubKeys: []string{key}})
+ if err := a.Store.CreateUser(store.User{Username: username, PubKeys: []string{key}}); err != nil {
+ return err
+ }
+ _ = a.Store.UnrevokePrincipal(fmt.Sprintf("%s@%s", username, a.Domain))
+ if fp, err := keyFingerprint(key); err == nil {
+ _ = a.Store.UnrevokeKey(fp)
+ }
+ return nil
}
func (a *Admin) AddUserKey(username, pubKeyAuthorized string) error {
@@ -90,15 +99,40 @@ func (a *Admin) AddUserKey(username, pubKeyAuthorized string) error {
if err != nil {
return err
}
- return a.Store.AddUserKey(username, key)
+ if err := a.Store.AddUserKey(username, key); err != nil {
+ return err
+ }
+ if fp, err := keyFingerprint(key); err == nil {
+ _ = a.Store.UnrevokeKey(fp)
+ }
+ return nil
}
+// RemoveUserKey drops a key from a user and revokes any outstanding
+// certificate issued for it, so a removed key can't keep authenticating via a
+// still-valid cert until its TTL lapses.
func (a *Admin) RemoveUserKey(username, pubKeyAuthorized string) error {
key, err := canonicalAuthorizedKey(pubKeyAuthorized)
if err != nil {
return err
}
- return a.Store.RemoveUserKey(username, key)
+ if err := a.Store.RemoveUserKey(username, key); err != nil {
+ return err
+ }
+ if fp, err := keyFingerprint(key); err == nil {
+ _ = a.Store.RevokeKey(fp)
+ }
+ return nil
+}
+
+// keyFingerprint returns the SHA-256 fingerprint of an authorized_keys line,
+// matching gossh.FingerprintSHA256(cert.Key) used during cert auth.
+func keyFingerprint(pubKeyAuthorized string) (string, error) {
+ pub, _, _, _, err := gossh.ParseAuthorizedKey([]byte(pubKeyAuthorized))
+ if err != nil {
+ return "", err
+ }
+ return gossh.FingerprintSHA256(pub), nil
}
// canonicalAuthorizedKey re-marshals a pasted authorized_keys line (which,
@@ -140,6 +174,11 @@ func (a *Admin) DeleteUser(username string) error {
if err := a.Store.DeletePasswordHash(username); err != nil && err != store.ErrNotFound {
return err
}
+ // Block any certificate this user still holds (valid until its TTL) from
+ // authenticating now that the account is gone.
+ if err := a.Store.RevokePrincipal(principal); err != nil {
+ return err
+ }
for _, r := range repos {
if err := a.Store.RemoveCollaborator(r.Name, principal); err != nil && err != store.ErrNotFound {
@@ -309,7 +348,27 @@ func (a *Admin) ApproveDomain(domain string) error {
return a.Resolver.Approve(domain)
}
-const minPasswordLength = 8
+const (
+ minPasswordLength = 8
+ // bcryptCost is above the library default (10) — appropriate for 2026
+ // hardware and still well under a noticeable login delay.
+ bcryptCost = 12
+)
+
+// dummyHash is a valid bcrypt hash (of an unguessable value) at bcryptCost.
+// VerifyPassword compares against it when a user or their credential doesn't
+// exist, so a login attempt against a non-existent account takes the same
+// time as one against a real account — closing the timing side channel that
+// otherwise reveals which usernames exist.
+var dummyHash = mustDummyHash()
+
+func mustDummyHash() []byte {
+ h, err := bcrypt.GenerateFromPassword([]byte("gitfed-nonexistent-account-sentinel"), bcryptCost)
+ if err != nil {
+ panic(err)
+ }
+ return h
+}
// SetPassword hashes and stores newPassword for username, replacing any
// existing one. Used both for an admin resetting someone's password and for
@@ -319,7 +378,7 @@ func (a *Admin) SetPassword(username, newPassword string) error {
if len(newPassword) < minPasswordLength {
return fmt.Errorf("admin: password must be at least %d characters", minPasswordLength)
}
- hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
+ hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcryptCost)
if err != nil {
return fmt.Errorf("admin: hash password: %w", err)
}
@@ -329,28 +388,33 @@ func (a *Admin) SetPassword(username, newPassword string) error {
// VerifyPassword reports whether password matches username's stored hash.
// ok is false (with a nil error) for a wrong password or a user with no
// password set yet — both are normal login-form outcomes, not failures.
+// It always performs one bcrypt comparison, even when the account or hash is
+// missing, to keep the response time independent of whether the user exists.
func (a *Admin) VerifyPassword(username, password string) (isAdmin bool, ok bool, err error) {
user, err := a.Store.GetUser(username)
- if err != nil {
- if err == store.ErrNotFound {
- return false, false, nil
- }
+ if err != nil && err != store.ErrNotFound {
return false, false, err
}
+ userExists := err == nil
+
hash, err := a.Store.GetPasswordHash(username)
- if err != nil {
- if err == store.ErrNotFound {
- return false, false, nil
- }
+ if err != nil && err != store.ErrNotFound {
return false, false, err
}
- if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil {
+
+ compareAgainst := []byte(hash)
+ if !userExists || hash == "" {
+ compareAgainst = dummyHash
+ }
+ match := bcrypt.CompareHashAndPassword(compareAgainst, []byte(password)) == nil
+
+ if !userExists || hash == "" || !match {
return false, false, nil
}
return user.IsAdmin, true, nil
}
-const sessionTTL = 30 * 24 * time.Hour
+const sessionTTL = 7 * 24 * time.Hour
func (a *Admin) CreateSession(principal, username string, isAdmin bool) (string, error) {
return a.Store.CreateSession(principal, username, isAdmin, sessionTTL)
internal/admin/revocation_test.go
diff --git a/internal/admin/revocation_test.go b/internal/admin/revocation_test.go
new file mode 100644
index 0000000..9bb0495
--- /dev/null
+++ b/internal/admin/revocation_test.go
@@ -0,0 +1,89 @@
+package admin
+
+import (
+ "crypto/ed25519"
+ "crypto/rand"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ gossh "golang.org/x/crypto/ssh"
+
+ "gitfed/internal/store"
+)
+
+func newTestKey(t *testing.T) (authorized, fingerprint string) {
+ t.Helper()
+ pub, _, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sshPub, err := gossh.NewPublicKey(pub)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return strings.TrimSpace(string(gossh.MarshalAuthorizedKey(sshPub))), gossh.FingerprintSHA256(sshPub)
+}
+
+// TestDeleteUserRevokesPrincipal checks that deleting a user blocks any cert
+// they still hold, and that re-creating the account lifts the block.
+func TestDeleteUserRevokesPrincipal(t *testing.T) {
+ s, err := store.Open(filepath.Join(t.TempDir(), "gitfed.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer s.Close()
+ a := New(s, nil, "local.test", t.TempDir())
+ key, _ := newTestKey(t)
+
+ if err := a.CreateUser("alice", key); err != nil {
+ t.Fatal(err)
+ }
+ if err := a.DeleteUser("alice"); err != nil {
+ t.Fatal(err)
+ }
+ if r, _ := s.IsPrincipalRevoked("alice@local.test"); !r {
+ t.Fatal("principal should be revoked after DeleteUser")
+ }
+
+ // Re-creating the account must clear the revocation.
+ if err := a.CreateUser("alice", key); err != nil {
+ t.Fatal(err)
+ }
+ if r, _ := s.IsPrincipalRevoked("alice@local.test"); r {
+ t.Fatal("principal should be un-revoked after re-creation")
+ }
+}
+
+// TestRemoveUserKeyRevokesKey checks that removing a key revokes certs issued
+// for it, and that re-adding it lifts the block.
+func TestRemoveUserKeyRevokesKey(t *testing.T) {
+ s, err := store.Open(filepath.Join(t.TempDir(), "gitfed.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer s.Close()
+ a := New(s, nil, "local.test", t.TempDir())
+
+ key1, _ := newTestKey(t)
+ key2, fp2 := newTestKey(t)
+
+ if err := a.CreateUser("bob", key1); err != nil {
+ t.Fatal(err)
+ }
+ if err := a.AddUserKey("bob", key2); err != nil {
+ t.Fatal(err)
+ }
+ if err := a.RemoveUserKey("bob", key2); err != nil {
+ t.Fatal(err)
+ }
+ if r, _ := s.IsKeyRevoked(fp2); !r {
+ t.Fatal("key should be revoked after RemoveUserKey")
+ }
+ if err := a.AddUserKey("bob", key2); err != nil {
+ t.Fatal(err)
+ }
+ if r, _ := s.IsKeyRevoked(fp2); r {
+ t.Fatal("key should be un-revoked after re-adding")
+ }
+}
internal/config/config.go
diff --git a/internal/config/config.go b/internal/config/config.go
index 8203e7d..1b00c42 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -36,7 +36,7 @@ func Default(domain, dataDir string) Config {
ListenSSH: ":2222",
ListenHTTP: ":8443",
TrustPolicy: store.TrustPolicyWhitelist,
- CertTTLHours: 48,
+ CertTTLHours: 24,
}
}
internal/federation/ssrf_test.go
diff --git a/internal/federation/ssrf_test.go b/internal/federation/ssrf_test.go
new file mode 100644
index 0000000..78ebc1b
--- /dev/null
+++ b/internal/federation/ssrf_test.go
@@ -0,0 +1,63 @@
+package federation
+
+import (
+ "net"
+ "testing"
+)
+
+func TestValidatePublicDomain(t *testing.T) {
+ valid := []string{
+ "instanceb.example",
+ "git.neuromancer.ovh",
+ "a.b.c.example.com",
+ }
+ for _, d := range valid {
+ if err := ValidatePublicDomain(d); err != nil {
+ t.Errorf("ValidatePublicDomain(%q) = %v, want nil", d, err)
+ }
+ }
+
+ invalid := []string{
+ "", // empty
+ "169.254.169.254", // IP literal (cloud metadata)
+ "127.0.0.1", // IP literal
+ "10.0.0.5", // private IP literal
+ "[::1]", // IPv6 literal
+ "localhost", // single label, no TLD
+ "evil.com:6379", // embedded port
+ "evil.com/path", // embedded path
+ "user@evil.com", // credentials
+ "https://evil.com", // scheme
+ "evil.com#frag", // fragment
+ "has space.example", // whitespace
+ }
+ for _, d := range invalid {
+ if err := ValidatePublicDomain(d); err == nil {
+ t.Errorf("ValidatePublicDomain(%q) = nil, want error", d)
+ }
+ }
+}
+
+func TestIsDisallowedIP(t *testing.T) {
+ blocked := []string{
+ "127.0.0.1", "::1", // loopback
+ "10.1.2.3", "172.16.5.5", "192.168.1.1", // RFC1918
+ "169.254.169.254", // link-local / cloud metadata
+ "100.64.0.1", // CGNAT
+ "0.0.0.0", // unspecified
+ "fd00::1", // ULA
+ "fe80::1", // link-local v6
+ }
+ for _, s := range blocked {
+ if !isDisallowedIP(net.ParseIP(s)) {
+ t.Errorf("isDisallowedIP(%s) = false, want true", s)
+ }
+ }
+
+ allowed := []string{"1.1.1.1", "8.8.8.8", "93.184.216.34", "2606:4700:4700::1111"}
+ for _, s := range allowed {
+ if isDisallowedIP(net.ParseIP(s)) {
+ t.Errorf("isDisallowedIP(%s) = true, want false", s)
+ }
+ }
+}
internal/federation/wellknown.go
diff --git a/internal/federation/wellknown.go b/internal/federation/wellknown.go
index 2c90091..f1e17e1 100644
--- a/internal/federation/wellknown.go
+++ b/internal/federation/wellknown.go
@@ -3,9 +3,14 @@
package federation
import (
+ "context"
"encoding/json"
"fmt"
+ "io"
+ "net"
"net/http"
+ "regexp"
+ "strings"
"time"
"gitfed/internal/ca"
@@ -38,19 +43,112 @@ func Handler(domain, contact, softwareVersion string, localCA *ca.CA) http.Handl
})
}
-var httpClient = &http.Client{Timeout: 10 * time.Second}
+// maxWellKnownBytes caps how much of a remote well-known response we read, so
+// a hostile or broken peer can't exhaust memory during discovery.
+const maxWellKnownBytes = 1 << 20 // 1 MiB
+
+// insecureClient is used only in dev/testing (insecure=true), where the peer
+// is a loopback instance with no TLS and possibly a private address — so it
+// deliberately does not apply the anti-SSRF dial guard.
+var insecureClient = &http.Client{Timeout: 10 * time.Second}
+
+// secureClient is used for real remote discovery. Its dialer refuses to
+// connect to non-public addresses and pins the connection to the exact IP it
+// validated, closing the DNS-rebinding window between check and dial.
+var secureClient = &http.Client{
+ Timeout: 10 * time.Second,
+ Transport: &http.Transport{
+ DialContext: guardedDial,
+ TLSHandshakeTimeout: 10 * time.Second,
+ ResponseHeaderTimeout: 10 * time.Second,
+ },
+}
+
+// hostnameRe matches a conventional public hostname (at least two dot-joined
+// labels, alphabetic TLD). It rejects bare hostnames, IP literals and
+// anything carrying a port, path, userinfo or scheme.
+var hostnameRe = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$`)
+
+// ValidatePublicDomain reports whether domain is safe to make an outbound
+// federation request to: a syntactically valid public hostname, not an IP
+// literal and with no embedded port/path/credentials. The IP-level block is
+// enforced again at dial time (guardedDial); this is the cheap early gate.
+func ValidatePublicDomain(domain string) error {
+ if domain == "" {
+ return fmt.Errorf("empty domain")
+ }
+ if strings.ContainsAny(domain, ":/?#@\\ ") {
+ return fmt.Errorf("domain %q must be a bare hostname (no scheme, port, path or credentials)", domain)
+ }
+ if net.ParseIP(domain) != nil {
+ return fmt.Errorf("domain %q must be a hostname, not an IP address", domain)
+ }
+ if !hostnameRe.MatchString(domain) {
+ return fmt.Errorf("domain %q is not a valid public hostname", domain)
+ }
+ 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) {
+ host, port, err := net.SplitHostPort(addr)
+ if err != nil {
+ return nil, err
+ }
+ ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
+ if err != nil {
+ return nil, err
+ }
+ d := &net.Dialer{Timeout: 10 * time.Second}
+ var lastErr error
+ for _, ip := range ips {
+ if isDisallowedIP(ip.IP) {
+ lastErr = fmt.Errorf("refusing to connect to non-public address %s (for %s)", ip.IP, host)
+ continue
+ }
+ conn, err := d.DialContext(ctx, network, net.JoinHostPort(ip.IP.String(), port))
+ if err == nil {
+ return conn, nil
+ }
+ lastErr = err
+ }
+ if lastErr == nil {
+ lastErr = fmt.Errorf("no address to dial for %s", host)
+ }
+ return nil, lastErr
+}
+
+// isDisallowedIP is true for any address that must never be the target of a
+// federation fetch: loopback, RFC1918/ULA private, link-local (covers the
+// 169.254.169.254 cloud-metadata endpoint), CGNAT, unspecified and multicast.
+func isDisallowedIP(ip net.IP) bool {
+ if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
+ ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast() {
+ return true
+ }
+ // Carrier-grade NAT 100.64.0.0/10, not covered by IsPrivate.
+ if ip4 := ip.To4(); ip4 != nil && ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127 {
+ return true
+ }
+ return false
+}
// Fetch retrieves and parses the well-known document for domain over HTTPS.
-// insecure switches to plain HTTP, for local development/testing only where
-// there's no real DNS name or TLS cert (e.g. federating two instances both
-// bound to 127.0.0.1) — never use it against a real remote instance.
+// insecure switches to plain HTTP and disables the anti-SSRF guards, for
+// local development/testing only where there's no real DNS name or TLS cert
+// (e.g. federating two instances both bound to 127.0.0.1) — never use it
+// against a real remote instance.
func Fetch(domain string, insecure bool) (*WellKnown, error) {
- scheme := "https"
+ scheme, client := "https", secureClient
if insecure {
- scheme = "http"
+ scheme, client = "http", insecureClient
+ } else if err := ValidatePublicDomain(domain); err != nil {
+ return nil, fmt.Errorf("federation: refusing to fetch %q: %w", domain, err)
}
+
url := fmt.Sprintf("%s://%s/.well-known/gitfed.json", scheme, domain)
- resp, err := httpClient.Get(url)
+ resp, err := client.Get(url)
if err != nil {
return nil, fmt.Errorf("federation: fetch %s: %w", url, err)
}
@@ -61,7 +159,7 @@ func Fetch(domain string, insecure bool) (*WellKnown, error) {
}
var doc WellKnown
- if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
+ if err := json.NewDecoder(io.LimitReader(resp.Body, maxWellKnownBytes)).Decode(&doc); err != nil {
return nil, fmt.Errorf("federation: decode %s: %w", url, err)
}
if doc.Domain != domain {
internal/i18n/i18n.go
diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go
new file mode 100644
index 0000000..b579d7e
--- /dev/null
+++ b/internal/i18n/i18n.go
@@ -0,0 +1,55 @@
+// Package i18n holds gitfed-web's translation strings and looks them up by
+// key. Every page's copy lives here instead of being hardcoded in the Go
+// templates, so the web UI can be served in more than one language.
+package i18n
+
+import "fmt"
+
+// Lang is a supported UI language. New languages are added by defining a
+// constant here and a dictionary for it in dict().
+type Lang string
+
+const (
+ EN Lang = "en"
+ FR Lang = "fr"
+)
+
+// Default is used whenever a request carries no usable language signal
+// (no cookie, no matching Accept-Language tag).
+const Default = EN
+
+// ParseLang validates a language tag from a cookie or path segment,
+// returning ok=false for anything gitfed doesn't have a dictionary for.
+func ParseLang(v string) (Lang, bool) {
+ switch Lang(v) {
+ case EN, FR:
+ return Lang(v), true
+ default:
+ return "", false
+ }
+}
+
+func dict(l Lang) map[string]string {
+ if l == FR {
+ return fr
+ }
+ return en
+}
+
+// T looks up key in lang's dictionary, falling back to English and then to
+// the key itself so a missing translation shows up as an obviously-wrong
+// string instead of a blank. With extra args, the result is passed through
+// fmt.Sprintf.
+func T(lang Lang, key string, args ...any) string {
+ s, ok := dict(lang)[key]
+ if !ok {
+ s, ok = en[key]
+ }
+ if !ok {
+ s = key
+ }
+ if len(args) > 0 {
+ return fmt.Sprintf(s, args...)
+ }
+ return s
+}
internal/i18n/strings_en.go
diff --git a/internal/i18n/strings_en.go b/internal/i18n/strings_en.go
new file mode 100644
index 0000000..fb91dbd
--- /dev/null
+++ b/internal/i18n/strings_en.go
@@ -0,0 +1,247 @@
+package i18n
+
+var en = map[string]string{
+ // ---------- nav / shell ----------
+ "nav.home": "Home",
+ "nav.explore": "Explore",
+ "nav.security": "Security",
+ "nav.dashboard": "Dashboard",
+ "nav.search_placeholder": "Search repos…",
+ "nav.login": "Log in",
+ "nav.signed_in_as": "Signed in as",
+ "nav.settings": "Settings",
+ "nav.admin": "Admin",
+ "nav.logout": "Log out",
+ "footer.changelog": "gitfed %s",
+
+ // ---------- explore ----------
+ "explore.title": "Explore public repositories",
+ "explore.filtered_by": "Filtered by topic",
+ "explore.clear": "clear",
+ "explore.col_repo": "Repo",
+ "explore.col_owner": "Owner",
+ "explore.col_topics": "Topics",
+ "explore.empty": "No public repositories yet.",
+
+ // ---------- landing ----------
+ "landing.title": "gitfed",
+ "landing.kicker": "self-hosted & federated git",
+ "landing.h1_1": "Your code. Your server.",
+ "landing.h1_2": "Your rules.",
+ "landing.lead": "gitfed is a git server you run yourself — so you keep ownership of your code and your data, without giving up the ability to collaborate with anyone, wherever they host their own instance.",
+ "landing.cta_explore": "Explore public repos",
+ "landing.cta_security": "How it's secured →",
+ "landing.pillar1_h": "Ownership",
+ "landing.pillar1_p": "Your code and your data stay on your machine, your server, your infrastructure. No third-party platform can mine it, shut it down, or cut off your access.",
+ "landing.pillar2_h": "Federation",
+ "landing.pillar2_p": "Each instance stays independent, but can trust others — a bit like email. One account, on your own instance, is enough to push code to an instance that trusts you.",
+ "landing.pillar3_h": "Control",
+ "landing.pillar3_p": "Public or private, read or write, per repo, per person. You decide — no platform-wide policy gets imposed on you.",
+ "landing.split_h": "One account at home, not one everywhere",
+ "landing.split_p": "When someone from another instance wants to collaborate on one of your repos, they don't create an account with you: their home instance signs a certificate proving who they are, and your instance checks that it trusts that instance. Once trust is established, everything happens locally — no network call on every `git push`.",
+ "landing.split_link_security": "More on security",
+ "landing.split_link_explore": "Explore repos",
+ "landing.diagram_you": "Your instance",
+ "landing.diagram_friend": "A friend's instance",
+ "landing.diagram_cert": "certificate",
+ "landing.diagram_trust": "trust verified",
+ "landing.diagram_no_account1": "no \"bob\" account",
+ "landing.diagram_no_account2": "on alice's instance",
+ "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 dashboard",
+
+ // ---------- security ----------
+ "security.title": "Security — gitfed",
+ "security.kicker": "security model",
+ "security.h1": "Why it's secure, explained simply",
+ "security.lead": "No unnecessary jargon: here's, step by step, how gitfed protects your code — from transport to your collaborators' identity.",
+
+ "security.s1_tag": "01 · transport",
+ "security.s1_h": "SSH only, never git over HTTP",
+ "security.s1_p": "gitfed exposes no git-over-HTTP protocol. Every read or write goes over SSH — a protocol proven for 25 years, with a single door to watch instead of two.",
+ "security.s1_diagram_client": "git clone/push",
+ "security.s1_diagram_open": "SSH — open",
+ "security.s1_diagram_http": "HTTP git — doesn't exist",
+ "security.s1_diagram_repos": "repos",
+ "security.s1_caption": "A single network entry point for git: the SSH port.",
+
+ "security.s2_tag": "02 · identity",
+ "security.s2_h": "Short-lived certificates, not shared passwords",
+ "security.s2_p": "Every instance generates its own certificate authority. Your instance signs a certificate proving who you are, valid for 24h by default — never a long-lived secret that can leak.",
+ "security.s2_diagram_haskey": "has an SSH key",
+ "security.s2_diagram_step1": "1. requests",
+ "security.s2_diagram_alice_instance": "alice's instance",
+ "security.s2_diagram_local_ca": "local authority (CA)",
+ "security.s2_diagram_signs": "signs 24h",
+ "security.s2_diagram_step2": "2. certificate",
+ "security.s2_diagram_bob_instance": "bob's instance",
+ "security.s2_diagram_trust1": "does it trust",
+ "security.s2_diagram_trust2": "this CA?",
+ "security.s2_diagram_access": "access",
+ "security.s2_diagram_local1": "verification is 100% local from here —",
+ "security.s2_diagram_local2": "no network call on every push",
+ "security.s2_caption": "alice asks her own instance for a certificate, presents it to bob's, which checks its trust in alice's instance.",
+
+ "security.s3_tag": "03 · trust",
+ "security.s3_h": "Trust between instances is explicit, never automatic",
+ "security.s3_p": "The first time an unknown instance shows up, gitfed reads its public profile (/.well-known/gitfed.json) then puts its key on hold — an admin has to approve it before any access is granted. Discovery is also capped at 10 new domains per minute, to prevent abuse.",
+ "security.s3_diagram_unknown": "Unknown domain",
+ "security.s3_diagram_unknown_sub": "contacts your instance",
+ "security.s3_diagram_discovery": "Discovery",
+ "security.s3_diagram_discovery_sub": "reads its public CA key",
+ "security.s3_diagram_pending": "Pending",
+ "security.s3_diagram_pending_sub": "until an admin acts",
+ "security.s3_diagram_approved": "Approved",
+ "security.s3_diagram_approved_sub": "an admin's decision",
+
+ "security.s4_tag": "04 · isolation",
+ "security.s4_h": "Web access and git access share nothing",
+ "security.s4_p": "The web UI password has nothing to do with your git access: it's hashed (bcrypt), never stored in clear text, and only opens an opaque, time-limited web session. A `git push` never depends on this password — only on your SSH key or certificate.",
+ "security.s4_fact1_h": "Precise per-repo roles",
+ "security.s4_fact1_p": "read, write, admin — never all-or-nothing.",
+ "security.s4_fact2_h": "Public ≠ writable",
+ "security.s4_fact2_p": "a public repo opens for reading, never for writing without an explicit grant.",
+ "security.s4_fact3_h": "Session cookies",
+ "security.s4_fact3_p": "HttpOnly, Secure, SameSite — unreachable from JavaScript.",
+ "security.s4_fact4_h": "Audit log",
+ "security.s4_fact4_p": "every access decision, allowed or denied, is recorded.",
+
+ // ---------- common ----------
+ "common.public": "public",
+ "common.private": "private",
+ "role.owner": "owner",
+ "role.read": "read",
+ "role.write": "write",
+ "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",
+
+ // ---------- settings ----------
+ "settings.title": "Settings",
+ "settings.tab_profile": "Profile",
+ "settings.tab_keys": "SSH keys",
+ "settings.tab_password": "Password",
+ "settings.logged_in_as": "Logged in as",
+ "settings.profile_note": "This password only signs you into the web UI. Git push/pull always goes over SSH with a key, independently of it.",
+ "settings.remove": "Remove",
+ "settings.no_keys": "No keys yet.",
+ "settings.add_key": "Add SSH key",
+ "settings.pubkey_label": "Public key (authorized_keys format)",
+ "settings.add": "Add",
+ "settings.change_password": "Change password",
+ "settings.current_password": "Current password",
+ "settings.new_password": "New password",
+ "settings.msg_key_added": "key added",
+ "settings.msg_key_removed": "key removed",
+ "settings.msg_wrong_password": "current password is incorrect",
+ "settings.msg_password_changed": "password changed",
+
+ // ---------- auth ----------
+ "auth.login": "Log in",
+ "auth.username": "Username",
+ "auth.password": "Password",
+ "auth.note": "Accounts are created by an instance admin — there's no self-registration. This only logs you into the web UI; git push/pull still goes over SSH with your key.",
+ "auth.invalid_login": "invalid username or password",
+ "auth.rate_limited": "too many login attempts, try again in a few minutes",
+ "auth.error": "unable to log you in right now, try again later",
+ "common.server_error": "an internal error occurred",
+
+ // ---------- search / changelog ----------
+ "search.title": "Search",
+ "search.no_matches": "No matches.",
+ "search.prompt": "Type something to search.",
+ "changelog.title": "Changelog",
+
+ // ---------- admin ----------
+ "admin.title": "Admin",
+ "admin.card_users": "Users",
+ "admin.card_users_sub": "%d admin(s), %d regular",
+ "admin.card_users_link": "Manage users",
+ "admin.card_trust": "Trust store",
+ "admin.card_trust_pending": "pending",
+ "admin.card_trust_sub": "federated domains known",
+ "admin.card_trust_link": "Review trust store",
+ "admin.card_audit": "Audit log",
+ "admin.card_audit_sub": "most recent events on record",
+ "admin.card_audit_link": "View audit log",
+ "admin.recent_activity": "Recent activity",
+ "admin.audit_allow": "allow",
+ "admin.audit_deny": "deny",
+
+ "admin.users_title": "Admin — Users",
+ "admin.users_col_username": "Username",
+ "admin.users_col_keys": "Keys",
+ "admin.revoke_admin": "Revoke admin",
+ "admin.make_admin": "Make admin",
+ "admin.confirm_delete_user": "Delete user",
+ "admin.users_empty": "No users yet.",
+ "admin.add_user": "Add user",
+ "admin.initial_password": "Initial password",
+ "admin.grant_admin": "Grant admin",
+ "admin.msg_user_created": "created user %s",
+ "admin.msg_user_deleted": "deleted user %s",
+ "admin.msg_user_updated": "updated %s",
+
+ "admin.trust_title": "Admin — Trust store",
+ "admin.trust_col_domain": "Domain",
+ "admin.trust_col_status": "Status",
+ "admin.trust_col_first_seen": "First seen",
+ "admin.trust_status_pending": "pending",
+ "admin.trust_status_trusted": "trusted",
+ "admin.approve": "Approve",
+ "admin.trust_empty": "No remote domains discovered yet.",
+ "admin.trust_note": "Domains show up here automatically the first time a collaborator on a remote domain is granted access to a repo. Under the default whitelist policy they stay pending until approved here.",
+ "admin.msg_domain_approved": "approved %s",
+
+ "admin.audit_title": "Admin — Audit log",
+ "admin.audit_col_time": "Time",
+ "admin.audit_col_action": "Action",
+ "admin.audit_col_principal": "Principal",
+ "admin.audit_col_repo_domain": "Repo/Domain",
+ "admin.audit_col_result": "Result",
+ "admin.audit_col_detail": "Detail",
+ "admin.audit_empty": "No events recorded yet.",
+ "admin.audit_note": "Showing the most recent 200 events.",
+
+ // ---------- repo ----------
+ "repo.owner": "owner",
+ "repo.copy_clone_url": "Copy clone URL",
+ "repo.empty": "This repository is empty.",
+ "repo.directory": "directory",
+ "repo.file": "file",
+ "repo.nothing_here": "Nothing here.",
+ "repo.tags": "Tags",
+ "repo.license": "License",
+ "repo.binary_file": "Binary file — not shown.",
+ "repo.settings_title": "settings",
+ "repo.visibility_topics": "Visibility & topics",
+ "repo.public_desc": "Public (readable by any authenticated principal)",
+ "repo.topics_label": "Topics (comma-separated)",
+ "repo.save": "Save",
+ "repo.col_principal": "Principal",
+ "repo.col_role": "Role",
+ "repo.revoke": "Revoke",
+ "repo.grant_collaborator": "Grant collaborator",
+ "repo.grant": "Grant",
+ "repo.grant_note": "Granting a collaborator on a domain not yet known to this instance triggers federation discovery of that domain's CA — an instance admin needs to approve it under Admin → Trust store (whitelist policy).",
+ "repo.confirm_delete": "Delete",
+ "repo.danger_zone": "Danger zone",
+ "repo.delete_record": "Delete repo record",
+ "repo.msg_saved": "saved settings",
+ "repo.msg_granted": "granted %s %s",
+ "repo.msg_revoked": "revoked %s",
+ "repo.msg_deleted": "deleted %s",
+}
internal/i18n/strings_fr.go
diff --git a/internal/i18n/strings_fr.go b/internal/i18n/strings_fr.go
new file mode 100644
index 0000000..9896e4a
--- /dev/null
+++ b/internal/i18n/strings_fr.go
@@ -0,0 +1,247 @@
+package i18n
+
+var fr = map[string]string{
+ // ---------- nav / shell ----------
+ "nav.home": "Accueil",
+ "nav.explore": "Explorer",
+ "nav.security": "Sécurité",
+ "nav.dashboard": "Tableau de bord",
+ "nav.search_placeholder": "Rechercher un dépôt…",
+ "nav.login": "Se connecter",
+ "nav.signed_in_as": "Connecté en tant que",
+ "nav.settings": "Paramètres",
+ "nav.admin": "Administration",
+ "nav.logout": "Se déconnecter",
+ "footer.changelog": "gitfed %s",
+
+ // ---------- explore ----------
+ "explore.title": "Explorer les dépôts publics",
+ "explore.filtered_by": "Filtré par sujet",
+ "explore.clear": "effacer",
+ "explore.col_repo": "Dépôt",
+ "explore.col_owner": "Propriétaire",
+ "explore.col_topics": "Sujets",
+ "explore.empty": "Aucun dépôt public pour le moment.",
+
+ // ---------- landing ----------
+ "landing.title": "gitfed",
+ "landing.kicker": "git auto-hébergé & fédéré",
+ "landing.h1_1": "Votre code. Votre serveur.",
+ "landing.h1_2": "Vos règles.",
+ "landing.lead": "gitfed est un serveur git que vous hébergez vous-même — pour garder la propriété de votre code et de vos données, sans renoncer à collaborer avec qui vous voulez, où que cette personne héberge son instance.",
+ "landing.cta_explore": "Explorer les dépôts publics",
+ "landing.cta_security": "Comment c'est sécurisé →",
+ "landing.pillar1_h": "Propriété",
+ "landing.pillar1_p": "Votre code et vos données restent sur votre machine, votre serveur, votre infrastructure. Aucune plateforme tierce ne peut les exploiter, les fermer, ou vous couper l'accès.",
+ "landing.pillar2_h": "Fédération",
+ "landing.pillar2_p": "Chaque instance reste indépendante, mais peut faire confiance à d'autres — comme le mail. Un seul compte, chez vous, suffit pour pousser du code sur une instance qui vous fait confiance.",
+ "landing.pillar3_h": "Contrôle",
+ "landing.pillar3_p": "Public ou privé, lecture ou écriture, dépôt par dépôt, personne par personne. C'est vous qui décidez — aucune règle de plateforme ne s'impose à vous.",
+ "landing.split_h": "Un compte chez vous, pas un compte partout",
+ "landing.split_p": "Quand une personne d'une autre instance veut collaborer sur un de vos dépôts, elle ne crée pas de compte chez vous : son instance d'origine signe un certificat qui prouve qui elle est, et votre instance vérifie qu'elle fait confiance à cette instance-là. Une fois la confiance établie, tout se passe localement — aucun appel réseau à chaque `git push`.",
+ "landing.split_link_security": "En savoir plus sur la sécurité",
+ "landing.split_link_explore": "Explorer les dépôts",
+ "landing.diagram_you": "Votre instance",
+ "landing.diagram_friend": "Instance d'un ami",
+ "landing.diagram_cert": "certificat",
+ "landing.diagram_trust": "confiance vérifiée",
+ "landing.diagram_no_account1": "pas de compte « bob »",
+ "landing.diagram_no_account2": "chez alice",
+ "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 au tableau de bord",
+
+ // ---------- security ----------
+ "security.title": "Sécurité — gitfed",
+ "security.kicker": "modèle de sécurité",
+ "security.h1": "Pourquoi c'est sécurisé, expliqué simplement",
+ "security.lead": "Pas de jargon inutile : voici, étape par étape, comment gitfed protège votre code — du transport jusqu'à l'identité de vos collaborateurs.",
+
+ "security.s1_tag": "01 · transport",
+ "security.s1_h": "Uniquement du SSH, jamais du git en HTTP",
+ "security.s1_p": "gitfed n'expose aucun protocole git par HTTP. Toute lecture ou écriture passe par SSH — un protocole éprouvé depuis 25 ans, avec une seule porte d'entrée à surveiller plutôt que deux.",
+ "security.s1_diagram_client": "git clone/push",
+ "security.s1_diagram_open": "SSH — ouvert",
+ "security.s1_diagram_http": "HTTP git — inexistant",
+ "security.s1_diagram_repos": "dépôts",
+ "security.s1_caption": "Une seule porte d'entrée réseau pour le git : le port SSH.",
+
+ "security.s2_tag": "02 · identité",
+ "security.s2_h": "Des certificats de courte durée, pas des mots de passe partagés",
+ "security.s2_p": "Chaque instance génère sa propre autorité de certification. Votre instance signe un certificat prouvant qui vous êtes, valable 24h par défaut — jamais un secret longue durée à faire fuiter.",
+ "security.s2_diagram_haskey": "a une clé SSH",
+ "security.s2_diagram_step1": "1. demande",
+ "security.s2_diagram_alice_instance": "instance d'alice",
+ "security.s2_diagram_local_ca": "autorité (CA) locale",
+ "security.s2_diagram_signs": "signe 24h",
+ "security.s2_diagram_step2": "2. certificat",
+ "security.s2_diagram_bob_instance": "instance de bob",
+ "security.s2_diagram_trust1": "a-t-elle confiance",
+ "security.s2_diagram_trust2": "en cette CA ?",
+ "security.s2_diagram_access": "accès",
+ "security.s2_diagram_local1": "vérification 100% locale ensuite —",
+ "security.s2_diagram_local2": "aucun appel réseau à chaque push",
+ "security.s2_caption": "alice demande un certificat à sa propre instance, le présente chez bob, qui vérifie sa confiance envers l'instance d'alice.",
+
+ "security.s3_tag": "03 · confiance",
+ "security.s3_h": "La confiance entre instances est explicite, jamais automatique",
+ "security.s3_p": "La première fois qu'une instance inconnue apparaît, gitfed consulte sa fiche publique (/.well-known/gitfed.json) puis met sa clé en attente — un administrateur doit l'approuver avant que le moindre accès soit accordé. La découverte est aussi limitée à 10 nouveaux domaines par minute, pour éviter tout abus.",
+ "security.s3_diagram_unknown": "Domaine inconnu",
+ "security.s3_diagram_unknown_sub": "contacte votre instance",
+ "security.s3_diagram_discovery": "Découverte",
+ "security.s3_diagram_discovery_sub": "lit sa clé publique CA",
+ "security.s3_diagram_pending": "En attente",
+ "security.s3_diagram_pending_sub": "tant qu'un admin n'a rien fait",
+ "security.s3_diagram_approved": "Approuvé",
+ "security.s3_diagram_approved_sub": "décision d'un admin",
+
+ "security.s4_tag": "04 · cloisonnement",
+ "security.s4_h": "L'accès web et l'accès git ne partagent rien",
+ "security.s4_p": "Le mot de passe de l'interface web n'a rien à voir avec vos accès git : il est haché (bcrypt), jamais stocké en clair, et n'ouvre qu'une session web opaque, à durée limitée. Un `git push` ne dépend jamais de ce mot de passe — uniquement de votre clé SSH ou de votre certificat.",
+ "security.s4_fact1_h": "Rôles précis par dépôt",
+ "security.s4_fact1_p": "lecture, écriture, admin, jamais tout ou rien.",
+ "security.s4_fact2_h": "Public ≠ inscriptible",
+ "security.s4_fact2_p": "un dépôt public s'ouvre en lecture, jamais en écriture sans droit explicite.",
+ "security.s4_fact3_h": "Cookies de session",
+ "security.s4_fact3_p": "HttpOnly, Secure, SameSite — inaccessibles en JavaScript.",
+ "security.s4_fact4_h": "Journal d'audit",
+ "security.s4_fact4_p": "chaque décision d'accès, autorisée ou refusée, est enregistrée.",
+
+ // ---------- common ----------
+ "common.public": "public",
+ "common.private": "privé",
+ "role.owner": "propriétaire",
+ "role.read": "lecture",
+ "role.write": "écriture",
+ "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",
+
+ // ---------- settings ----------
+ "settings.title": "Paramètres",
+ "settings.tab_profile": "Profil",
+ "settings.tab_keys": "Clés SSH",
+ "settings.tab_password": "Mot de passe",
+ "settings.logged_in_as": "Connecté en tant que",
+ "settings.profile_note": "Ce mot de passe ne sert qu'à se connecter à l'interface web. Le push/pull git passe toujours par SSH avec une clé, indépendamment de lui.",
+ "settings.remove": "Retirer",
+ "settings.no_keys": "Aucune clé pour le moment.",
+ "settings.add_key": "Ajouter une clé SSH",
+ "settings.pubkey_label": "Clé publique (format authorized_keys)",
+ "settings.add": "Ajouter",
+ "settings.change_password": "Changer le mot de passe",
+ "settings.current_password": "Mot de passe actuel",
+ "settings.new_password": "Nouveau mot de passe",
+ "settings.msg_key_added": "clé ajoutée",
+ "settings.msg_key_removed": "clé retirée",
+ "settings.msg_wrong_password": "mot de passe actuel incorrect",
+ "settings.msg_password_changed": "mot de passe changé",
+
+ // ---------- auth ----------
+ "auth.login": "Se connecter",
+ "auth.username": "Nom d'utilisateur",
+ "auth.password": "Mot de passe",
+ "auth.note": "Les comptes sont créés par un administrateur de l'instance — pas d'inscription libre. Ceci ne fait que vous connecter à l'interface web ; le push/pull git passe toujours par SSH avec votre clé.",
+ "auth.invalid_login": "nom d'utilisateur ou mot de passe invalide",
+ "auth.rate_limited": "trop de tentatives de connexion, réessayez dans quelques minutes",
+ "auth.error": "impossible de vous connecter pour le moment, réessayez plus tard",
+ "common.server_error": "une erreur interne est survenue",
+
+ // ---------- search / changelog ----------
+ "search.title": "Recherche",
+ "search.no_matches": "Aucun résultat.",
+ "search.prompt": "Tapez quelque chose pour rechercher.",
+ "changelog.title": "Changelog",
+
+ // ---------- admin ----------
+ "admin.title": "Administration",
+ "admin.card_users": "Utilisateurs",
+ "admin.card_users_sub": "%d admin(s), %d classique(s)",
+ "admin.card_users_link": "Gérer les utilisateurs",
+ "admin.card_trust": "Confiance fédérée",
+ "admin.card_trust_pending": "en attente",
+ "admin.card_trust_sub": "domaines fédérés connus",
+ "admin.card_trust_link": "Revoir la confiance fédérée",
This commit's diff is too large to show in full — the list above still shows every changed file.