Gitfed
bastien-mrq/gitfed/ Commits/ b1adb87

Pinned remote repos, federated notifications, commit history

- Dashboard bookmarks for repos on other instances (store.PinnedRepo): self-added, grant nothing by themselves, work instantly for public repos and just give a link for a private one you already have git access to. - Federated notifications (internal/federation/notify.go): GrantCollaborator now tells a remote collaborator's home instance about the grant, signed with the local CA and verified by the receiver against a fresh fetch of the sender's published CA key. Deliberately advisory-only — no persisted trust relationship needed for this, since accepting one only pins a link, it never grants access. Rate-limited per source IP, entries expire out of relevance fast since the real access check always happens on the granting instance. New /.well-known/gitfed-notify endpoint; ingress.yaml switched to a path prefix to cover it. - Commit history view (internal/gitexec.ListCommits + /repo-commits). - HTTPS clone URL on repo pages now explicitly labeled read-only. - THIRD_PARTY_LICENSES.md split out of the README. GrantCollaborator's Ops signature grew an actor parameter (who's granting), threaded through the RPC client/server and both call sites (web + TUI) — needed so the notification can say who granted access.

bastien-mrq 2026-07-28 20:40 commit b1adb87190df88df3d79d8d06f0a6a0ed97b3170 parent 6622c2363889e6203695e61f156396737d243628
30 files changed +1248 −79
M CHANGELOG.md +8 −0
M README.fr.md +1 −0
M README.md +1 −0
A THIRD_PARTY_LICENSES.md +51 −0
M cmd/gitfed-server/main.go +2 −1
M cmd/gitfed-tui/actions.go +2 −1
M cmd/gitfed-web/handlers_dashboard.go +92 −1
A cmd/gitfed-web/handlers_notifications.go +82 −0
M cmd/gitfed-web/handlers_repo.go +5 −2
A cmd/gitfed-web/handlers_repo_commits.go +63 −0
M cmd/gitfed-web/render.go +30 −1
M cmd/gitfed-web/routes.go +8 −0
M deploy/k8s/ingress.yaml +5 −2
M internal/admin/admin.go +101 −5
M internal/admin/admin_test.go +17 −4
M internal/admin/revocation_test.go +2 −2
M internal/adminrpc/adminrpc_test.go +6 −1
M internal/adminrpc/client.go +42 −2
M internal/adminrpc/protocol.go +48 −0
M internal/adminrpc/server.go +61 −1
M internal/ca/ca.go +9 −0
A internal/federation/notify.go +244 −0
M internal/federation/resolver.go +6 −0
M internal/gitexec/gitexec.go +53 −0
M internal/i18n/strings_en.go +54 −27
M internal/i18n/strings_fr.go +54 −27
M internal/opsconnect/connect.go +7 −1
A internal/store/notifications.go +124 −0
A internal/store/pins.go +49 −0
M internal/store/store.go +21 −1
CHANGELOG.md
diff --git a/CHANGELOG.md b/CHANGELOG.md index fd4c901..ebb6ec3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.9.0 + +- **Pinned remote repos**: bookmark a repo on another instance from your dashboard — works instantly for public repos (no login needed there), and just gives a link for a private one you already have git access to. +- **Federated notifications**: when someone grants you access to a repo on their instance, you now get a notification on your own instance (a bell in the nav) telling you about it — signed by the granting instance's CA and verified against a fresh fetch of its public key, with no persisted trust relationship required since it's purely advisory (accepting one only pins the link; it never grants access by itself). Rate-limited per source IP. +- **Commit history**: a new "Commits" view on every repo page (author, date, subject, short hash). +- The HTTPS clone URL on a repo page is now explicitly labeled "read-only". +- New `THIRD_PARTY_LICENSES.md` listing every Go dependency and its license, split out of the main README. + ## 0.8.0 - Public repos can now be cloned anonymously over HTTPS — `git clone https://<domain>/<owner>/<repo>.git` works with no account, no SSH key, nothing. Strictly read-only: there is no `git-receive-pack` route over HTTP, `repo.Public` is re-checked on every request, and private/nonexistent repos 404 identically. Push still only ever works over SSH. The repo page shows both the HTTPS and SSH clone URLs for public repos now.
README.fr.md
diff --git a/README.fr.md b/README.fr.md index 4289d0f..69452a2 100644 --- a/README.fr.md +++ b/README.fr.md @@ -66,6 +66,7 @@ instance en fonctionnement. | [`docs/security/AUDIT.md`](docs/security/AUDIT.md) | L'audit de sécurité pré-production et ce qui en a été corrigé. | | [`DESIGN.md`](DESIGN.md) | Le document de conception d'origine — le « pourquoi » derrière l'architecture, écrit avant le début de l'implémentation. | | [`CHANGELOG.md`](CHANGELOG.md) | Historique des versions (également servi sur `/changelog` dans l'interface web). | +| [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md) | Chaque dépendance Go et sa licence. | ## Le faire tourner en local
README.md
diff --git a/README.md b/README.md index 962a6ec..090d425 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ instance. | [`docs/security/AUDIT.md`](docs/security/AUDIT.md) *(French)* | The pre-production security audit and what was fixed as a result. | | [`DESIGN.md`](DESIGN.md) *(French)* | The original design rationale — the "why" behind the architecture, written before implementation started. | | [`CHANGELOG.md`](CHANGELOG.md) | Version history (also served at `/changelog` in the web UI). | +| [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md) | Every Go dependency and its license. | ## Running it locally
THIRD_PARTY_LICENSES.md
diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md new file mode 100644 index 0000000..4c593e2 --- /dev/null +++ b/THIRD_PARTY_LICENSES.md @@ -0,0 +1,51 @@ +# Third-party licenses + +gitfed itself is [GNU AGPLv3](LICENSE). It's built on the Go dependencies +listed below, every one of them permissively licensed (MIT or BSD-3-Clause) +— fully compatible with being used inside an AGPLv3 project. This list is +generated from `go.mod`; run `go list -m all` to see the exact resolved +versions for a given build. + +## Direct dependencies + +| Module | License | Used for | +|---|---|---| +| [github.com/charmbracelet/bubbletea](https://github.com/charmbracelet/bubbletea) | MIT | `gitfed-tui`'s terminal UI framework | +| [github.com/charmbracelet/bubbles](https://github.com/charmbracelet/bubbles) | MIT | `gitfed-tui`'s list/text-input components | +| [github.com/charmbracelet/lipgloss](https://github.com/charmbracelet/lipgloss) | MIT | `gitfed-tui`'s styling | +| [go.etcd.io/bbolt](https://github.com/etcd-io/bbolt) | MIT | the embedded database (`internal/store`) | +| [golang.org/x/crypto](https://cs.opensource.google/go/x/crypto) | BSD-3-Clause | SSH server/client, certificates, bcrypt | + +## Indirect dependencies (pulled in by the above) + +| Module | License | +|---|---| +| [github.com/atotto/clipboard](https://github.com/atotto/clipboard) | BSD-3-Clause | +| [github.com/aymanbagabas/go-osc52/v2](https://github.com/aymanbagabas/go-osc52) | MIT | +| [github.com/charmbracelet/colorprofile](https://github.com/charmbracelet/colorprofile) | MIT | +| [github.com/charmbracelet/x/ansi](https://github.com/charmbracelet/x) | MIT | +| [github.com/charmbracelet/x/cellbuf](https://github.com/charmbracelet/x) | MIT | +| [github.com/charmbracelet/x/term](https://github.com/charmbracelet/x) | MIT | +| [github.com/clipperhouse/displaywidth](https://github.com/clipperhouse/displaywidth) | MIT | +| [github.com/clipperhouse/stringish](https://github.com/clipperhouse/stringish) | MIT | +| [github.com/clipperhouse/uax29/v2](https://github.com/clipperhouse/uax29) | MIT | +| [github.com/erikgeiser/coninput](https://github.com/erikgeiser/coninput) | MIT | +| [github.com/lucasb-eyer/go-colorful](https://github.com/lucasb-eyer/go-colorful) | MIT | +| [github.com/mattn/go-isatty](https://github.com/mattn/go-isatty) | MIT | +| [github.com/mattn/go-localereader](https://github.com/mattn/go-localereader) | MIT | +| [github.com/mattn/go-runewidth](https://github.com/mattn/go-runewidth) | MIT | +| [github.com/muesli/ansi](https://github.com/muesli/ansi) | MIT | +| [github.com/muesli/cancelreader](https://github.com/muesli/cancelreader) | MIT | +| [github.com/muesli/termenv](https://github.com/muesli/termenv) | MIT | +| [github.com/rivo/uniseg](https://github.com/rivo/uniseg) | MIT | +| [github.com/sahilm/fuzzy](https://github.com/sahilm/fuzzy) | MIT | +| [github.com/xo/terminfo](https://github.com/xo/terminfo) | MIT | +| [github.com/yuin/goldmark](https://github.com/yuin/goldmark) | MIT | +| [golang.org/x/sys](https://cs.opensource.google/go/x/sys) | BSD-3-Clause | +| [golang.org/x/text](https://cs.opensource.google/go/x/text) | BSD-3-Clause | + +## Frontend + +The web UI (`cmd/gitfed-web`) has no JavaScript dependencies at all — no +build step, no `node_modules`, no CDN. Its one script block is handwritten +and inlined (see `docs/ARCHITECTURE.md` §4).
cmd/gitfed-server/main.go
diff --git a/cmd/gitfed-server/main.go b/cmd/gitfed-server/main.go index d16a1fe..32dd1d8 100644 --- a/cmd/gitfed-server/main.go +++ b/cmd/gitfed-server/main.go @@ -123,7 +123,7 @@ func run(configPath string) error { resolver := federation.NewResolver(st, cfg.Domain, cfg.InsecureFederation) - adminOps := admin.New(st, resolver, cfg.Domain, cfg.ReposDir) + adminOps := admin.New(st, resolver, cfg.Domain, cfg.ReposDir, localCA) go func() { rpcServer := adminrpc.NewServer(adminOps, cfg.AdminSocketPath()) if err := rpcServer.ListenAndServe(); err != nil { @@ -136,6 +136,7 @@ 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) + mux.Handle("POST /.well-known/gitfed-notify", federation.NotifyHandler(st, cfg.Domain, cfg.InsecureFederation)) srv := &http.Server{ Addr: cfg.ListenHTTP, Handler: mux,
cmd/gitfed-tui/actions.go
diff --git a/cmd/gitfed-tui/actions.go b/cmd/gitfed-tui/actions.go index 2794a56..a074a9c 100644 --- a/cmd/gitfed-tui/actions.go +++ b/cmd/gitfed-tui/actions.go @@ -159,6 +159,7 @@ func (m model) openACLForSelectedRepo() (tea.Model, tea.Cmd) { func (m model) openAddCollaboratorForm() (tea.Model, tea.Cmd) { a := m.ops repoName := m.selectedRepo + actor := "admin@" + m.domain m.activeForm = newForm("Add collaborator", []string{"Principal", "Role"}, []string{"bob@instanceb.example", "read|write|admin"}, @@ -168,7 +169,7 @@ func (m model) openAddCollaboratorForm() (tea.Model, tea.Cmd) { if !role.Valid() { return statusMsg{err: fmt.Errorf("role must be read, write or admin")} } - if err := a.GrantCollaborator(repoName, principal, role); err != nil { + if err := a.GrantCollaborator(repoName, principal, actor, role); err != nil { return statusMsg{err: err} } return statusMsg{text: principal + " granted " + string(role) + " on " + repoName}
cmd/gitfed-web/handlers_dashboard.go
diff --git a/cmd/gitfed-web/handlers_dashboard.go b/cmd/gitfed-web/handlers_dashboard.go index f668def..a799c50 100644 --- a/cmd/gitfed-web/handlers_dashboard.go +++ b/cmd/gitfed-web/handlers_dashboard.go @@ -44,6 +44,38 @@ var dashboardTpl = newTpl("dashboard", ` <div class="gf-repo-row"><span class="muted">{{t .Lang "dashboard.empty"}}</span></div> {{end}} </div> + +<div class="gf-page-head" style="margin-top:2rem;"> + <h1 style="font-size:1.1rem;">{{t .Lang "dashboard.pinned_title"}}</h1> + <details class="gf-new-repo"> + <summary class="gf-btn" style="margin:0;">+ {{t .Lang "dashboard.pin_add"}}</summary> + <form class="card" method="post" action="/pins" style="margin-top:0.75rem;"> + <label>{{t .Lang "dashboard.pin_input_label"}}</label> + <input name="url" required placeholder="chez-moi.fr/alice/mon-projet"> + <button type="submit">{{t .Lang "dashboard.pin_add"}}</button> + </form> + </details> +</div> +<p class="muted" style="margin-top:-0.6rem;">{{t .Lang "dashboard.pinned_note"}}</p> + +<div class="gf-card gf-repo-list"> +{{range .Pins}} + <div class="gf-repo-row"> + <div class="gf-repo-icon">{{icon "network"}}</div> + <div class="gf-repo-main"> + <div class="name"><a href="https://{{.Domain}}/r/{{.Repo}}">{{.Repo}}</a></div> + <div class="meta">{{.Domain}}</div> + </div> + <form class="inline" method="post" action="/pins/remove"> + <input type="hidden" name="domain" value="{{.Domain}}"> + <input type="hidden" name="repo" value="{{.Repo}}"> + <button class="linklike" type="submit" title="{{t $.Lang "dashboard.pin_remove"}}" aria-label="{{t $.Lang "dashboard.pin_remove"}}">{{icon "close"}}</button> + </form> + </div> +{{else}} + <div class="gf-repo-row"><span class="muted">{{t .Lang "dashboard.pins_empty"}}</span></div> +{{end}} +</div> `) type dashboardRepo struct { @@ -76,13 +108,20 @@ func (s *server) handleDashboard(w http.ResponseWriter, r *http.Request) { } } + pins, err := s.ops.ListPinnedRepos(sess.Principal) + if err != nil { + s.serverError(w, r, err) + return + } + var buf bytes.Buffer _ = dashboardTpl.Execute(&buf, struct { Repos []dashboardRepo + Pins []store.PinnedRepo Username, Lang string PublicCount, SharedCount int Flash template.HTML - }{mine, sess.Username, string(lang), publicCount, sharedCount, flash(r)}) + }{mine, pins, sess.Username, string(lang), publicCount, sharedCount, flash(r)}) s.render(w, r, i18n.T(lang, "dashboard.title"), "dashboard", template.HTML(buf.String())) } @@ -133,3 +172,55 @@ func (s *server) handleCreateRepo(w http.ResponseWriter, r *http.Request) { } redirectWithMsg(w, r, "/repo-settings/"+name, i18n.T(lang, "dashboard.msg_created", name), false) } + +// maxPinsPerUser is a sanity cap, not a meaningful security boundary — pins +// grant no access, they're just bookmarks — but an unbounded list would +// still be an easy way to bloat one user's slice of the store. +const maxPinsPerUser = 200 + +func (s *server) handleAddPin(w http.ResponseWriter, r *http.Request) { + sess, _ := s.currentSession(r) + lang := s.lang(r) + + domain, repo, ok := parsePinInput(r.FormValue("url")) + if !ok { + redirectWithMsg(w, r, "/dashboard", i18n.T(lang, "dashboard.msg_pin_invalid"), true) + return + } + if existing, err := s.ops.ListPinnedRepos(sess.Principal); err == nil && len(existing) >= maxPinsPerUser { + redirectWithMsg(w, r, "/dashboard", i18n.T(lang, "dashboard.msg_pin_quota"), true) + return + } + if err := s.ops.PinRepo(sess.Principal, domain, repo, ""); err != nil { + redirectWithMsg(w, r, "/dashboard", err.Error(), true) + return + } + redirectWithMsg(w, r, "/dashboard", i18n.T(lang, "dashboard.msg_pinned", repo), false) +} + +func (s *server) handleRemovePin(w http.ResponseWriter, r *http.Request) { + sess, _ := s.currentSession(r) + if err := s.ops.UnpinRepo(sess.Principal, r.FormValue("domain"), r.FormValue("repo")); err != nil { + s.serverError(w, r, err) + return + } + http.Redirect(w, r, "/dashboard", http.StatusSeeOther) +} + +// parsePinInput accepts anything from a bare "domain/owner/repo" to a full +// URL someone pasted straight out of their browser's address bar, +// including gitfed's own "/r/" repo-view prefix if that's what they copied. +func parsePinInput(s string) (domain, repo string, ok bool) { + s = strings.TrimSpace(s) + s = strings.TrimPrefix(s, "https://") + s = strings.TrimPrefix(s, "http://") + s = strings.TrimSuffix(s, "/") + s = strings.TrimSuffix(s, ".git") + + domain, rest, found := strings.Cut(s, "/") + rest = strings.TrimPrefix(rest, "r/") + if !found || domain == "" || rest == "" { + return "", "", false + } + return domain, rest, true +}
cmd/gitfed-web/handlers_notifications.go
diff --git a/cmd/gitfed-web/handlers_notifications.go b/cmd/gitfed-web/handlers_notifications.go new file mode 100644 index 0000000..675707f --- /dev/null +++ b/cmd/gitfed-web/handlers_notifications.go @@ -0,0 +1,82 @@ +package main + +import ( + "bytes" + "html/template" + "net/http" + + "gitfed/internal/i18n" + "gitfed/internal/store" +) + +var notificationsTpl = newTpl("notifications", ` +{{.Flash}} +<div class="gf-page-head"><h1>{{t .Lang "notif.title"}}</h1></div> +<p class="muted" style="margin-top:-0.6rem;">{{t .Lang "notif.note"}}</p> + +<div class="gf-card gf-repo-list"> +{{range .Notifications}} + <div class="gf-repo-row"> + <div class="gf-repo-icon">{{icon "bell"}}</div> + <div class="gf-repo-main"> + <div class="name">{{.Repo}}</div> + <div class="meta">{{t $.Lang "notif.granted_by"}} {{.Actor}} · {{.FromDomain}} · {{roleLabel $.Lang .Role}}</div> + </div> + {{if eq (print .Status) "pending"}} + <form class="inline" method="post" action="/notifications/accept"> + <input type="hidden" name="id" value="{{.ID}}"> + <button type="submit" class="gf-btn primary">{{t $.Lang "notif.accept"}}</button> + </form> + <form class="inline" method="post" action="/notifications/dismiss"> + <input type="hidden" name="id" value="{{.ID}}"> + <button type="submit" class="linklike" title="{{t $.Lang "notif.dismiss"}}" aria-label="{{t $.Lang "notif.dismiss"}}">{{icon "close"}}</button> + </form> + {{else if eq (print .Status) "accepted"}} + <span class="badge trusted">{{t $.Lang "notif.status_accepted"}}</span> + {{else}} + <span class="badge plain">{{t $.Lang "notif.status_dismissed"}}</span> + {{end}} + </div> +{{else}} + <div class="gf-repo-row"><span class="muted">{{t .Lang "notif.empty"}}</span></div> +{{end}} +</div> +`) + +func (s *server) handleNotifications(w http.ResponseWriter, r *http.Request) { + sess, _ := s.currentSession(r) + lang := s.lang(r) + + notifs, err := s.ops.ListNotifications(sess.Principal) + if err != nil { + s.serverError(w, r, err) + return + } + + var buf bytes.Buffer + _ = notificationsTpl.Execute(&buf, struct { + Notifications []store.Notification + Lang string + Flash template.HTML + }{notifs, string(lang), flash(r)}) + s.render(w, r, i18n.T(lang, "notif.title"), "", template.HTML(buf.String())) +} + +func (s *server) handleAcceptNotification(w http.ResponseWriter, r *http.Request) { + sess, _ := s.currentSession(r) + if err := s.ops.AcceptNotification(sess.Principal, r.FormValue("id")); err != nil { + s.serverError(w, r, err) + return + } + lang := s.lang(r) + redirectWithMsg(w, r, "/notifications", i18n.T(lang, "notif.msg_accepted"), false) +} + +func (s *server) handleDismissNotification(w http.ResponseWriter, r *http.Request) { + sess, _ := s.currentSession(r) + if err := s.ops.DismissNotification(sess.Principal, r.FormValue("id")); err != nil { + s.serverError(w, r, err) + return + } + http.Redirect(w, r, "/notifications", http.StatusSeeOther) +}
cmd/gitfed-web/handlers_repo.go
diff --git a/cmd/gitfed-web/handlers_repo.go b/cmd/gitfed-web/handlers_repo.go index e05bc3f..c6237e5 100644 --- a/cmd/gitfed-web/handlers_repo.go +++ b/cmd/gitfed-web/handlers_repo.go @@ -90,6 +90,7 @@ var repoTpl = newTpl("repo", ` {{if .Repo.Public}} <div class="gf-clone-url"> <span class="gf-clone-label">HTTPS</span> + <span class="badge plain" title="{{t .Lang "repo.https_readonly_hint"}}">{{t .Lang "repo.read_only"}}</span> <code>{{.HTTPSCloneURL}}</code> <button type="button" class="linklike" data-copy="{{.HTTPSCloneURL}}" title="{{t .Lang "repo.copy_clone_url"}}" aria-label="{{t .Lang "repo.copy_clone_url"}}">{{icon "copy"}}</button> </div> @@ -99,6 +100,7 @@ var repoTpl = newTpl("repo", ` <code>{{.CloneURL}}</code> <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 not .Empty}}<a href="/repo-commits/{{.Repo.Name}}" class="gf-btn">{{icon "history"}} {{t .Lang "repo.commits_title"}}</a>{{end}} {{if .CanAdminister}}<a href="/repo-settings/{{.Repo.Name}}" class="gf-btn">{{t .Lang "nav.settings"}}</a>{{end}} </div> @@ -450,14 +452,15 @@ func (s *server) handleRepoSettingsSave(w http.ResponseWriter, r *http.Request) 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 { + actor, ok := s.canAdminister(r, name) + if !ok { http.NotFound(w, r) return } back := "/repo-settings/" + url.PathEscape(name) principal := r.FormValue("principal") role := store.Role(r.FormValue("role")) - if err := s.ops.GrantCollaborator(name, principal, role); err != nil { + if err := s.ops.GrantCollaborator(name, principal, actor, role); err != nil { redirectWithMsg(w, r, back, err.Error(), true) return }
cmd/gitfed-web/handlers_repo_commits.go
diff --git a/cmd/gitfed-web/handlers_repo_commits.go b/cmd/gitfed-web/handlers_repo_commits.go new file mode 100644 index 0000000..cb14a4d --- /dev/null +++ b/cmd/gitfed-web/handlers_repo_commits.go @@ -0,0 +1,63 @@ +package main + +import ( + "bytes" + "html/template" + "net/http" + + "gitfed/internal/gitexec" + "gitfed/internal/i18n" + "gitfed/internal/store" +) + +// maxCommitsShown caps how much history one page renders — plenty for +// browsing, and bounds how much `git log` output gitfed-web ever buffers. +const maxCommitsShown = 200 + +var repoCommitsTpl = newTpl("repo-commits", ` +{{.Flash}} +<div class="gf-crumbs"><a href="/r/{{.Repo.Name}}">{{.Repo.Name}}</a><span class="sep">/</span>{{t .Lang "repo.commits_title"}}</div> +<div class="gf-repo-head"> + <h1>{{t .Lang "repo.commits_title"}}</h1> +</div> + +<div class="gf-card gf-commit-list"> +{{range .Commits}} + <div class="gf-commit-row"> + <div class="gf-commit-main"> + <div class="subject">{{.Subject}}</div> + <div class="meta">{{.Author}} · {{.Date.Local.Format "2006-01-02 15:04"}}</div> + </div> + <code class="gf-commit-hash">{{.ShortHash}}</code> + </div> +{{else}} + <div class="gf-commit-row"><span class="muted">{{t .Lang "repo.commits_empty"}}</span></div> +{{end}} +</div> +`) + +func (s *server) handleRepoCommits(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("repo") + lang := s.lang(r) + + repo, err := s.ops.GetRepo(name) + if err != nil || !s.canView(r, repo) { + http.NotFound(w, r) + return + } + + commits, _, err := s.ops.ListCommits(name, maxCommitsShown) + if err != nil { + s.serverError(w, r, err) + return + } + + var buf bytes.Buffer + _ = repoCommitsTpl.Execute(&buf, struct { + Repo store.Repo + Lang string + Commits []gitexec.Commit + Flash template.HTML + }{repo, string(lang), commits, flash(r)}) + s.render(w, r, name+" — "+i18n.T(lang, "repo.commits_title"), "home", template.HTML(buf.String())) +}
cmd/gitfed-web/render.go
diff --git a/cmd/gitfed-web/render.go b/cmd/gitfed-web/render.go index 2192183..03d5695 100644 --- a/cmd/gitfed-web/render.go +++ b/cmd/gitfed-web/render.go @@ -138,6 +138,9 @@ const iconSprite = `<svg width="0" height="0" style="position:absolute" aria-hid <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> +<symbol id="ic-close" viewBox="0 0 24 24"><path d="M5.5 5.5l13 13M18.5 5.5l-13 13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></symbol> +<symbol id="ic-bell" viewBox="0 0 24 24"><path d="M6 10.5c0-3.3 2.7-6 6-6s6 2.7 6 6v3.3l1.6 2.7H4.4L6 13.8V10.5Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M9.8 18.5a2.3 2.3 0 0 0 4.4 0" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol> +<symbol id="ic-history" viewBox="0 0 24 24"><circle cx="12" cy="12.5" r="8" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M12 8v4.7l3.3 2" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M8 3.3 5 6M16 3.3 19 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol> </defs> </svg>` @@ -209,6 +212,14 @@ const shellHeadSrc = `<!doctype html> .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); } + /* --- notification bell --- */ + .gf-bell { position: relative; display: flex; align-items: center; color: var(--text-dim); flex-shrink: 0; padding: 0.3rem; border-radius: 7px; } + .gf-bell:hover { color: var(--text); background: var(--surface-2); } + .gf-bell-badge { + position: absolute; top: -2px; right: -2px; min-width: 15px; height: 15px; padding: 0 3px; border-radius: 999px; + background: var(--danger-fg); color: var(--canvas); font-size: 0.62rem; font-weight: 700; line-height: 15px; text-align: center; + } + /* --- 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; } .gf-profile { position: relative; flex-shrink: 0; } @@ -356,6 +367,16 @@ const shellHeadSrc = `<!doctype html> .gf-repo-main .name { font-family: var(--mono); font-size: 0.92rem; font-weight: 600; } .gf-repo-main .name a:hover { color: var(--accent); } .gf-repo-main .meta { font-size: 0.78rem; color: var(--text-faint); margin-top: 0.15rem; } + + .gf-commit-list { display: flex; flex-direction: column; } + .gf-commit-row { display: flex; align-items: center; gap: 0.9rem; padding: 0.8rem 1.1rem; border-bottom: 1px solid var(--border); } + .gf-commit-row:last-child { border-bottom: none; } + .gf-commit-row:hover { background: var(--surface-2); } + .gf-commit-main { flex: 1; min-width: 0; } + .gf-commit-main .subject { font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .gf-commit-main .meta { font-size: 0.78rem; color: var(--text-faint); margin-top: 0.2rem; } + .gf-commit-hash { flex-shrink: 0; font-size: 0.78rem; color: var(--text-dim); background: var(--surface-2); padding: 0.15rem 0.5rem; border-radius: 5px; } + .gf-new-repo summary { list-style: none; cursor: pointer; } .gf-new-repo summary::-webkit-details-marker { display: none; } .gf-new-repo[open] summary { margin-bottom: 0.5rem; } @@ -481,6 +502,9 @@ const shellHeadSrc = `<!doctype html> <a href="/lang/fr?next={{.NextPath}}"{{if eq .Lang "fr"}} class="active"{{end}}>FR</a> </div> {{if .LoggedIn}} + <a href="/notifications" class="gf-bell" title="{{t .Lang "nav.notifications"}}" aria-label="{{t .Lang "nav.notifications"}}"> + {{icon "bell"}}{{if .PendingNotifs}}<span class="gf-bell-badge">{{.PendingNotifs}}</span>{{end}} + </a> <div class="gf-profile"> <button class="gf-profile-trigger" id="profileTrigger" aria-haspopup="true" aria-expanded="false" aria-controls="profileMenu"> <span class="gf-avatar" title="{{.Username}}">{{.Initials}}</span> @@ -584,15 +608,20 @@ var shellTpl = newTpl("shell", shellSrc) func (s *server) render(w http.ResponseWriter, r *http.Request, title, active string, body template.HTML) { sess, loggedIn := s.currentSession(r) + var pendingNotifs int + if loggedIn { + pendingNotifs, _ = s.ops.CountPendingNotifications(sess.Principal) + } w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = shellTpl.Execute(w, struct { Title, Domain, Active, Username, Version, Initials, SearchQuery, Lang, NextPath string LoggedIn, IsAdmin bool + PendingNotifs int 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), + loggedIn, sess.IsAdmin, pendingNotifs, body, template.HTML(brandMark), template.HTML(iconSprite), }) }
cmd/gitfed-web/routes.go
diff --git a/cmd/gitfed-web/routes.go b/cmd/gitfed-web/routes.go index 32f4e2f..75afabb 100644 --- a/cmd/gitfed-web/routes.go +++ b/cmd/gitfed-web/routes.go @@ -7,6 +7,7 @@ func (s *server) routes(mux *http.ServeMux) { mux.HandleFunc("GET /{$}", s.handleLanding) mux.HandleFunc("GET /r/{repo...}", s.handleRepoView) mux.HandleFunc("GET /repo-blob/{repo...}", s.handleRepoBlob) + mux.HandleFunc("GET /repo-commits/{repo...}", s.handleRepoCommits) mux.HandleFunc("GET /login", s.handleLoginForm) mux.HandleFunc("POST /login", s.handleLogin) mux.HandleFunc("POST /logout", s.handleLogout) @@ -41,6 +42,13 @@ func (s *server) routes(mux *http.ServeMux) { mux.HandleFunc("POST /settings/keys/remove", s.requireLogin(s.handleRemoveOwnKey)) mux.HandleFunc("POST /settings/password", s.requireLogin(s.handleChangePassword)) + mux.HandleFunc("POST /pins", s.requireLogin(s.handleAddPin)) + mux.HandleFunc("POST /pins/remove", s.requireLogin(s.handleRemovePin)) + + mux.HandleFunc("GET /notifications", s.requireLogin(s.handleNotifications)) + mux.HandleFunc("POST /notifications/accept", s.requireLogin(s.handleAcceptNotification)) + mux.HandleFunc("POST /notifications/dismiss", s.requireLogin(s.handleDismissNotification)) + // Admin — instance-admin accounts only. mux.HandleFunc("GET /admin", s.requireAdmin(s.handleAdminIndex)) mux.HandleFunc("GET /admin/users", s.requireAdmin(s.handleAdminUsersList))
deploy/k8s/ingress.yaml
diff --git a/deploy/k8s/ingress.yaml b/deploy/k8s/ingress.yaml index 10e358b..27d956c 100644 --- a/deploy/k8s/ingress.yaml +++ b/deploy/k8s/ingress.yaml @@ -14,8 +14,11 @@ spec: - host: git.neuromancer.ovh http: paths: - - path: /.well-known/gitfed.json - pathType: Exact + # Prefix, not Exact: also covers /.well-known/gitfed-notify + # (federated notification receiver) without needing a second rule + # every time a new well-known path is added. + - path: /.well-known/ + pathType: Prefix backend: service: name: gitfed-wellknown
internal/admin/admin.go
diff --git a/internal/admin/admin.go b/internal/admin/admin.go index ce3eb1c..679c3c5 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -5,6 +5,7 @@ package admin import ( "fmt" + "log" "strings" "time" @@ -12,6 +13,7 @@ import ( gossh "golang.org/x/crypto/ssh" "gitfed/internal/acl" + "gitfed/internal/ca" "gitfed/internal/federation" "gitfed/internal/gitexec" "gitfed/internal/store" @@ -51,14 +53,24 @@ type Ops interface { ListRepoTree(name, path string) (entries []gitexec.TreeEntry, found bool, err error) GetRepoFile(name, path string) (content string, found bool, err error) GetRepoBranch(name string) (branch string, found bool, err error) + ListCommits(name string, limit int) (commits []gitexec.Commit, found bool, err error) GetACL(repoName string) (store.ACL, error) - GrantCollaborator(repoName, principal string, role store.Role) error + GrantCollaborator(repoName, principal, actor string, role store.Role) error RevokeCollaborator(repoName, principal string) error ListTrustedCAs() ([]store.TrustedCA, error) ApproveDomain(domain string) error + PinRepo(principal, domain, repo, label string) error + UnpinRepo(principal, domain, repo string) error + ListPinnedRepos(principal string) ([]store.PinnedRepo, error) + + ListNotifications(principal string) ([]store.Notification, error) + CountPendingNotifications(principal string) (int, error) + AcceptNotification(principal, id string) error + DismissNotification(principal, id string) error + ListAudit(limit int) ([]store.AuditEvent, error) } @@ -67,12 +79,13 @@ type Admin struct { Resolver *federation.Resolver Domain string ReposDir string + CA *ca.CA // signs outbound federated notifications; see GrantCollaborator } var _ Ops = (*Admin)(nil) -func New(st *store.Store, resolver *federation.Resolver, domain, reposDir string) *Admin { - return &Admin{Store: st, Resolver: resolver, Domain: domain, ReposDir: reposDir} +func New(st *store.Store, resolver *federation.Resolver, domain, reposDir string, localCA *ca.CA) *Admin { + return &Admin{Store: st, Resolver: resolver, Domain: domain, ReposDir: reposDir, CA: localCA} } // CreateUser registers a new local user with an initial SSH public key @@ -323,11 +336,22 @@ func (a *Admin) GetRepoBranch(name string) (string, bool, error) { return branch, ok, nil } +func (a *Admin) ListCommits(name string, limit int) ([]gitexec.Commit, bool, error) { + repo, err := a.Store.GetRepo(name) + if err != nil { + return nil, false, err + } + return gitexec.ListCommits(repo.Path, limit) +} + // GrantCollaborator adds/updates a collaborator's role on a repo. If the // principal belongs to a remote domain, it first resolves trust for that // domain (§5.2/§6); for the whitelist policy this leaves the domain pending // until an admin approves it, but the collaborator entry is still recorded. -func (a *Admin) GrantCollaborator(repoName, principal string, role store.Role) error { +// actor is who's granting it (a logged-in session's principal, or a +// synthetic value for TUI-driven grants) — used only to tell a remote +// collaborator's own instance who granted them access, see notifyGrant. +func (a *Admin) GrantCollaborator(repoName, principal, actor string, role store.Role) error { _, domain, ok := splitPrincipal(principal) if !ok { return fmt.Errorf("admin: invalid principal %q, expected user@domain", principal) @@ -337,7 +361,30 @@ func (a *Admin) GrantCollaborator(repoName, principal string, role store.Role) e return fmt.Errorf("admin: resolve trust for %q: %w", domain, err) } } - return acl.Grant(a.Store, repoName, principal, role) + if err := acl.Grant(a.Store, repoName, principal, role); err != nil { + return err + } + if domain != a.Domain && a.CA != nil { + go a.notifyGrant(repoName, principal, domain, actor, role) + } + return nil +} + +// notifyGrant tells principal's home instance about a grant it just +// received. Best-effort and asynchronous by design: the notification is +// purely advisory (see internal/federation/notify.go) — the grant itself +// already happened and is real regardless of whether this ever arrives, +// so nothing should block on it or surface its failure to the granter. +func (a *Admin) notifyGrant(repoName, principal, domain, actor string, role store.Role) { + err := federation.SendNotify(a.CA, a.Domain, domain, federation.NotifyPayload{ + Principal: principal, + Repo: repoName, + Role: string(role), + Actor: actor, + }, a.Resolver.Insecure()) + if err != nil { + log.Printf("admin: notify %s about grant on %s: %v", principal, repoName, err) + } } func (a *Admin) RevokeCollaborator(repoName, principal string) error { @@ -348,6 +395,55 @@ func (a *Admin) ApproveDomain(domain string) error { return a.Resolver.Approve(domain) } +// PinRepo, UnpinRepo and ListPinnedRepos back a purely local bookmark list +// (store.PinnedRepo) — see its doc comment. There's no validation here that +// domain/repo actually exists or is reachable: pinning is just a note to +// self, and a bad one just produces a link that 404s. +func (a *Admin) PinRepo(principal, domain, repo, label string) error { + return a.Store.PinRepo(principal, domain, repo, label) +} + +func (a *Admin) UnpinRepo(principal, domain, repo string) error { + return a.Store.UnpinRepo(principal, domain, repo) +} + +func (a *Admin) ListPinnedRepos(principal string) ([]store.PinnedRepo, error) { + return a.Store.ListPinnedRepos(principal) +} + +func (a *Admin) ListNotifications(principal string) ([]store.Notification, error) { + return a.Store.ListNotifications(principal) +} + +func (a *Admin) CountPendingNotifications(principal string) (int, error) { + return a.Store.CountPendingNotifications(principal) +} + +// AcceptNotification marks a pending notification accepted and, as a +// convenience, pins the repo it was about — the whole point of accepting +// one is "yes, remember this for me." It grants no access: whether the +// underlying collaborator grant is still valid is checked independently, +// by the granting instance, whenever the repo is actually visited. +func (a *Admin) AcceptNotification(principal, id string) error { + notifs, err := a.Store.ListNotifications(principal) + if err != nil { + return err + } + for _, n := range notifs { + if n.ID == id { + if err := a.Store.PinRepo(principal, n.FromDomain, n.Repo, ""); err != nil { + return err + } + break + } + } + return a.Store.SetNotificationStatus(principal, id, store.NotificationAccepted) +} + +func (a *Admin) DismissNotification(principal, id string) error { + return a.Store.SetNotificationStatus(principal, id, store.NotificationDismissed) +} + const ( minPasswordLength = 8 // bcryptCost is above the library default (10) — appropriate for 2026
internal/admin/admin_test.go
diff --git a/internal/admin/admin_test.go b/internal/admin/admin_test.go index 420699e..97adb47 100644 --- a/internal/admin/admin_test.go +++ b/internal/admin/admin_test.go @@ -9,9 +9,22 @@ import ( gossh "golang.org/x/crypto/ssh" + "gitfed/internal/ca" "gitfed/internal/store" ) +// testCA gives each test its own throwaway CA — GrantCollaborator's +// federated-notify side effect needs a.CA to be non-nil, even for tests +// that never grant a remote collaborator. +func testCA(t *testing.T) *ca.CA { + t.Helper() + c, err := ca.LoadOrCreate(t.TempDir()) + if err != nil { + t.Fatalf("load test CA: %v", err) + } + return c +} + // TestCreateUserStripsKeyComment guards against a real bug: a key pasted // straight from a .pub file carries a "user@host" comment, but the SSH // protocol never transmits comments during pubkey auth, so the server @@ -24,7 +37,7 @@ func TestCreateUserStripsKeyComment(t *testing.T) { t.Fatalf("open store: %v", err) } defer s.Close() - a := New(s, nil, "local.test", t.TempDir()) + a := New(s, nil, "local.test", t.TempDir(), testCA(t)) pub, _, err := ed25519.GenerateKey(rand.Reader) if err != nil { @@ -62,7 +75,7 @@ func TestPasswordLoginFlow(t *testing.T) { t.Fatalf("open store: %v", err) } defer s.Close() - a := New(s, nil, "local.test", t.TempDir()) + a := New(s, nil, "local.test", t.TempDir(), testCA(t)) if err := a.CreateUser("alice", validTestKey(t)); err != nil { t.Fatalf("CreateUser: %v", err) @@ -111,7 +124,7 @@ func TestSetPasswordRejectsShortPassword(t *testing.T) { t.Fatalf("open store: %v", err) } defer s.Close() - a := New(s, nil, "local.test", t.TempDir()) + a := New(s, nil, "local.test", t.TempDir(), testCA(t)) if err := a.CreateUser("alice", validTestKey(t)); err != nil { t.Fatalf("CreateUser: %v", err) @@ -127,7 +140,7 @@ func TestCheckAccessRespectsRoles(t *testing.T) { t.Fatalf("open store: %v", err) } defer s.Close() - a := New(s, nil, "local.test", t.TempDir()) + a := New(s, nil, "local.test", t.TempDir(), testCA(t)) if err := a.CreateRepo("alice/demo", "alice"); err != nil { t.Fatalf("CreateRepo: %v", err)
internal/admin/revocation_test.go
diff --git a/internal/admin/revocation_test.go b/internal/admin/revocation_test.go index 9bb0495..f0b28c8 100644 --- a/internal/admin/revocation_test.go +++ b/internal/admin/revocation_test.go @@ -33,7 +33,7 @@ func TestDeleteUserRevokesPrincipal(t *testing.T) { t.Fatal(err) } defer s.Close() - a := New(s, nil, "local.test", t.TempDir()) + a := New(s, nil, "local.test", t.TempDir(), testCA(t)) key, _ := newTestKey(t) if err := a.CreateUser("alice", key); err != nil { @@ -63,7 +63,7 @@ func TestRemoveUserKeyRevokesKey(t *testing.T) { t.Fatal(err) } defer s.Close() - a := New(s, nil, "local.test", t.TempDir()) + a := New(s, nil, "local.test", t.TempDir(), testCA(t)) key1, _ := newTestKey(t) key2, fp2 := newTestKey(t)
internal/adminrpc/adminrpc_test.go
diff --git a/internal/adminrpc/adminrpc_test.go b/internal/adminrpc/adminrpc_test.go index eabf422..b907b53 100644 --- a/internal/adminrpc/adminrpc_test.go +++ b/internal/adminrpc/adminrpc_test.go @@ -6,6 +6,7 @@ import ( "time" "gitfed/internal/admin" + "gitfed/internal/ca" "gitfed/internal/federation" "gitfed/internal/store" ) @@ -24,7 +25,11 @@ func TestErrNotFoundSurvivesRPC(t *testing.T) { } defer st.Close() - a := admin.New(st, federation.NewResolver(st, "local.test", true), "local.test", dir) + localCA, err := ca.LoadOrCreate(filepath.Join(dir, "ca")) + if err != nil { + t.Fatalf("load CA: %v", err) + } + a := admin.New(st, federation.NewResolver(st, "local.test", true), "local.test", dir, localCA) if err := a.CreateRepo("alice/demo", "alice"); err != nil { t.Fatalf("create repo: %v", err) }
internal/adminrpc/client.go
diff --git a/internal/adminrpc/client.go b/internal/adminrpc/client.go index 2254eab..25c5182 100644 --- a/internal/adminrpc/client.go +++ b/internal/adminrpc/client.go @@ -119,8 +119,8 @@ func (c *Client) GetACL(repoName string) (store.ACL, error) { return out, err } -func (c *Client) GrantCollaborator(repoName, principal string, role store.Role) error { - err := c.call(methodGrantCollaborator, collaboratorArgs{Repo: repoName, Principal: principal, Role: role}, nil) +func (c *Client) GrantCollaborator(repoName, principal, actor string, role store.Role) error { + err := c.call(methodGrantCollaborator, collaboratorArgs{Repo: repoName, Principal: principal, Actor: actor, Role: role}, nil) return err } @@ -231,3 +231,43 @@ func (c *Client) CheckAccess(repoName, principal string, want store.Role) (store err := c.call(methodCheckAccess, checkAccessArgs{Repo: repoName, Principal: principal, Want: want}, &out) return out.Role, out.OK, err } + +func (c *Client) ListCommits(name string, limit int) ([]gitexec.Commit, bool, error) { + var out listCommitsResult + err := c.call(methodListCommits, nameLimitArgs{Name: name, Limit: limit}, &out) + return out.Commits, out.Found, err +} + +func (c *Client) PinRepo(principal, domain, repo, label string) error { + return c.call(methodPinRepo, pinArgs{Principal: principal, Domain: domain, Repo: repo, Label: label}, nil) +} + +func (c *Client) UnpinRepo(principal, domain, repo string) error { + return c.call(methodUnpinRepo, pinArgs{Principal: principal, Domain: domain, Repo: repo}, nil) +} + +func (c *Client) ListPinnedRepos(principal string) ([]store.PinnedRepo, error) { + var out listPinsResult + err := c.call(methodListPinnedRepos, principalArgs{Principal: principal}, &out) + return out.Pins, err +} + +func (c *Client) ListNotifications(principal string) ([]store.Notification, error) { + var out listNotifsResult + err := c.call(methodListNotifications, principalArgs{Principal: principal}, &out) + return out.Notifications, err +} + +func (c *Client) CountPendingNotifications(principal string) (int, error) { + var out countResult + err := c.call(methodCountPendingNotifs, principalArgs{Principal: principal}, &out) + return out.Count, err +} + +func (c *Client) AcceptNotification(principal, id string) error { + return c.call(methodAcceptNotification, notificationIDArgs{Principal: principal, ID: id}, nil) +} + +func (c *Client) DismissNotification(principal, id string) error { + return c.call(methodDismissNotification, notificationIDArgs{Principal: principal, ID: id}, nil) +}
internal/adminrpc/protocol.go
diff --git a/internal/adminrpc/protocol.go b/internal/adminrpc/protocol.go index 34a1bf0..604aae1 100644 --- a/internal/adminrpc/protocol.go +++ b/internal/adminrpc/protocol.go @@ -45,6 +45,15 @@ const ( methodGetSession = "GetSession" methodDeleteSession = "DeleteSession" methodCheckAccess = "CheckAccess" + methodListCommits = "ListCommits" + + methodPinRepo = "PinRepo" + methodUnpinRepo = "UnpinRepo" + methodListPinnedRepos = "ListPinnedRepos" + methodListNotifications = "ListNotifications" + methodCountPendingNotifs = "CountPendingNotifications" + methodAcceptNotification = "AcceptNotification" + methodDismissNotification = "DismissNotification" ) // request is the envelope sent by the client for every call. @@ -76,6 +85,7 @@ type createRepoArgs struct { type collaboratorArgs struct { Repo string `json:"repo"` Principal string `json:"principal"` + Actor string `json:"actor,omitempty"` Role store.Role `json:"role,omitempty"` } @@ -195,3 +205,41 @@ type checkAccessResult struct { Role store.Role `json:"role"` OK bool `json:"ok"` } + +type pinArgs struct { + Principal string `json:"principal"` + Domain string `json:"domain"` + Repo string `json:"repo"` + Label string `json:"label,omitempty"` +} + +type principalArgs struct { + Principal string `json:"principal"` +} + +type listPinsResult struct { + Pins []store.PinnedRepo `json:"pins"` +} + +type listNotifsResult struct { + Notifications []store.Notification `json:"notifications"` +} + +type countResult struct { + Count int `json:"count"` +} + +type notificationIDArgs struct { + Principal string `json:"principal"` + ID string `json:"id"` +} + +type nameLimitArgs struct { + Name string `json:"name"` + Limit int `json:"limit"` +} + +type listCommitsResult struct { + Commits []gitexec.Commit `json:"commits"` + Found bool `json:"found"` +}
internal/adminrpc/server.go
diff --git a/internal/adminrpc/server.go b/internal/adminrpc/server.go index a5a4092..1ded3ca 100644 --- a/internal/adminrpc/server.go +++ b/internal/adminrpc/server.go @@ -135,7 +135,7 @@ func (s *Server) dispatch(req wireRequest) (any, error) { if err := json.Unmarshal(req.Args, &a); err != nil { return nil, err } - return nil, s.ops.GrantCollaborator(a.Repo, a.Principal, a.Role) + return nil, s.ops.GrantCollaborator(a.Repo, a.Principal, a.Actor, a.Role) case methodRevokeCollaborator: var a collaboratorArgs @@ -286,6 +286,66 @@ func (s *Server) dispatch(req wireRequest) (any, error) { role, ok, err := s.ops.CheckAccess(a.Repo, a.Principal, a.Want) return checkAccessResult{Role: role, OK: ok}, err + case methodListCommits: + var a nameLimitArgs + if err := json.Unmarshal(req.Args, &a); err != nil { + return nil, err + } + commits, found, err := s.ops.ListCommits(a.Name, a.Limit) + return listCommitsResult{Commits: commits, Found: found}, err + + case methodPinRepo: + var a pinArgs + if err := json.Unmarshal(req.Args, &a); err != nil { + return nil, err + } + return nil, s.ops.PinRepo(a.Principal, a.Domain, a.Repo, a.Label) + + case methodUnpinRepo: + var a pinArgs + if err := json.Unmarshal(req.Args, &a); err != nil { + return nil, err + } + return nil, s.ops.UnpinRepo(a.Principal, a.Domain, a.Repo) + + case methodListPinnedRepos: + var a principalArgs + if err := json.Unmarshal(req.Args, &a); err != nil { + return nil, err + } + pins, err := s.ops.ListPinnedRepos(a.Principal) + return listPinsResult{Pins: pins}, err + + case methodListNotifications: + var a principalArgs + if err := json.Unmarshal(req.Args, &a); err != nil { + return nil, err + } + notifs, err := s.ops.ListNotifications(a.Principal) + return listNotifsResult{Notifications: notifs}, err + + case methodCountPendingNotifs: + var a principalArgs + if err := json.Unmarshal(req.Args, &a); err != nil { + return nil, err + } + count, err := s.ops.CountPendingNotifications(a.Principal) + return countResult{Count: count}, err + + case methodAcceptNotification: + var a notificationIDArgs + if err := json.Unmarshal(req.Args, &a); err != nil { + return nil, err + } + return nil, s.ops.AcceptNotification(a.Principal, a.ID) + + case methodDismissNotification: + var a notificationIDArgs + if err := json.Unmarshal(req.Args, &a); err != nil { + return nil, err + } + return nil, s.ops.DismissNotification(a.Principal, a.ID) + default: return nil, fmt.Errorf("adminrpc: unknown method %q", req.Method) }
internal/ca/ca.go
diff --git a/internal/ca/ca.go b/internal/ca/ca.go index 3f77be2..a22d737 100644 --- a/internal/ca/ca.go +++ b/internal/ca/ca.go @@ -88,6 +88,15 @@ func (c *CA) PublicKey() ssh.PublicKey { return c.pub } +// SignBytes signs arbitrary data with the CA's private key. Used outside +// certificate issuance for exactly one thing: proving authorship of +// outbound federated notifications (see internal/federation) — a receiving +// instance verifies it against the sender's CA public key, the same one +// already published for certificate trust. +func (c *CA) SignBytes(data []byte) (*ssh.Signature, error) { + return c.signer.Sign(rand.Reader, data) +} + // IssueParams describes a certificate to be issued for a local user. type IssueParams struct { Username string
internal/federation/notify.go
diff --git a/internal/federation/notify.go b/internal/federation/notify.go new file mode 100644 index 0000000..340e0fa --- /dev/null +++ b/internal/federation/notify.go @@ -0,0 +1,244 @@ +package federation + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "strings" + "sync" + "time" + + "golang.org/x/crypto/ssh" + + "gitfed/internal/ca" + "gitfed/internal/store" +) + +// This file implements a small, deliberately non-authoritative federated +// notification: when a repo owner grants a collaborator on another +// instance, that instance is told "one of your users was just granted +// access to a repo here." The notification is advisory only — it never +// grants anything by itself. The actual access is (or isn't) real entirely +// independently of whether the notification ever arrives, is verified, or +// is even read: it's enforced by the granting instance's own ACL check +// whenever the repo is actually used over SSH. Accepting a notification on +// the receiving side just adds a bookmark (see store.PinnedRepo) to make +// finding the repo easier. +// +// Because of that, this deliberately doesn't require the two instances to +// have any persisted trust relationship first (unlike certificate trust, +// which gates real access and so needs admin approval under the whitelist +// policy). Verifying a notification just means checking a fresh signature +// against the sender's current published CA key — the same one-time +// lookup Fetch already does for certificate trust, just not persisted here +// since nothing sensitive is being decided. + +// NotifyPayload is the signed content of a notification. +type NotifyPayload struct { + FromDomain string `json:"from_domain"` + Principal string `json:"principal"` // recipient, "<user>@<recipient-domain>" + Repo string `json:"repo"` + Role string `json:"role"` + Actor string `json:"actor"` // who granted it, "<user>@<from_domain>" + IssuedAt int64 `json:"issued_at"` +} + +// notifyEnvelope is the wire format POSTed to /.well-known/gitfed-notify. +// Payload is kept as raw bytes so the signature is verified against +// exactly what was transmitted, never a re-marshaled (and therefore +// possibly different) copy. +type notifyEnvelope struct { + Payload json.RawMessage `json:"payload"` + SigFormat string `json:"sig_format"` + SigBlob string `json:"sig_blob"` // base64 +} + +const ( + maxNotifyBytes = 16 << 10 // this payload is a few hundred bytes; anything bigger isn't legitimate + notifyFreshnessWindow = 5 * time.Minute +) + +// SendNotify signs payload (filling in FromDomain and IssuedAt) and POSTs it +// to toDomain. Errors are always non-fatal to the caller — see the one call +// site, admin.Admin.GrantCollaborator, for why a failed notification must +// never fail the grant itself. +func SendNotify(localCA *ca.CA, fromDomain, toDomain string, payload NotifyPayload, insecure bool) error { + payload.FromDomain = fromDomain + payload.IssuedAt = time.Now().Unix() + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return err + } + sig, err := localCA.SignBytes(payloadBytes) + if err != nil { + return err + } + body, err := json.Marshal(notifyEnvelope{ + Payload: payloadBytes, + SigFormat: sig.Format, + SigBlob: base64.StdEncoding.EncodeToString(sig.Blob), + }) + if err != nil { + return err + } + + scheme, client := "https", secureClient + if insecure { + scheme, client = "http", insecureClient + } else if err := ValidatePublicDomain(toDomain); err != nil { + return fmt.Errorf("federation: refusing to notify %q: %w", toDomain, err) + } + + url := fmt.Sprintf("%s://%s/.well-known/gitfed-notify", scheme, toDomain) + resp, err := client.Post(url, "application/json", strings.NewReader(string(body))) + if err != nil { + return fmt.Errorf("federation: notify %s: %w", url, err) + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return fmt.Errorf("federation: notify %s: status %s", url, resp.Status) + } + return nil +} + +// notifyLimiter bounds inbound notification POSTs per source IP. Every +// request that passes the cheap local checks triggers an outbound +// well-known fetch to verify the claimed sender, so without a limit here a +// flood of bogus notifications would be an amplification/DoS vector even +// though no single request is expensive on its own. +type notifyLimiter struct { + mu sync.Mutex + hits map[string][]time.Time +} + +const ( + maxNotifiesPerIP = 20 + notifyWindow = time.Minute +) + +func (l *notifyLimiter) allow(ip string) bool { + l.mu.Lock() + defer l.mu.Unlock() + if l.hits == nil { + l.hits = make(map[string][]time.Time) + } + cutoff := time.Now().Add(-notifyWindow) + kept := l.hits[ip][:0] + for _, t := range l.hits[ip] { + if t.After(cutoff) { + kept = append(kept, t) + } + } + if len(kept) >= maxNotifiesPerIP { + l.hits[ip] = kept + return false + } + l.hits[ip] = append(kept, time.Now()) + return true +} + +// NotifyHandler serves POST /.well-known/gitfed-notify. +func NotifyHandler(st *store.Store, localDomain string, insecure bool) http.Handler { + limiter := &notifyLimiter{} + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !limiter.allow(requestIP(r)) { + http.Error(w, "rate limited", http.StatusTooManyRequests) + return + } + + body, err := io.ReadAll(io.LimitReader(r.Body, maxNotifyBytes)) + if err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + var env notifyEnvelope + if err := json.Unmarshal(body, &env); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + var payload NotifyPayload + if err := json.Unmarshal(env.Payload, &payload); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + // Cheap local checks before any network call: the recipient must + // actually be local, and the claimed sender must look like a real + // public hostname. + _, principalDomain, ok := strings.Cut(payload.Principal, "@") + if !ok || principalDomain != localDomain { + http.NotFound(w, r) + return + } + if !insecure { + if err := ValidatePublicDomain(payload.FromDomain); err != nil { + http.Error(w, "invalid from_domain", http.StatusBadRequest) + return + } + } + age := time.Since(time.Unix(payload.IssuedAt, 0)) + if age < -time.Minute || age > notifyFreshnessWindow { + http.Error(w, "stale notification", http.StatusBadRequest) + return + } + + sigBlob, err := base64.StdEncoding.DecodeString(env.SigBlob) + if err != nil { + http.Error(w, "bad signature encoding", http.StatusBadRequest) + return + } + + // The one network call: fetch the claimed sender's current CA key + // fresh, same as certificate-trust discovery does, but never + // persisted here — this notification being advisory-only means it + // doesn't need (and per the whitelist policy, shouldn't get) the + // same admin-approval gate that granting real access does. + doc, err := Fetch(payload.FromDomain, insecure) + if err != nil { + http.Error(w, "could not verify sender", http.StatusBadGateway) + return + } + pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(doc.CAPublicKey)) + if err != nil { + http.Error(w, "sender has no usable CA key", http.StatusBadGateway) + return + } + if err := pub.Verify(env.Payload, &ssh.Signature{Format: env.SigFormat, Blob: sigBlob}); err != nil { + http.Error(w, "invalid signature", http.StatusForbidden) + return + } + + _ = st.CreateNotification(store.Notification{ + Principal: payload.Principal, + FromDomain: payload.FromDomain, + Repo: payload.Repo, + Role: payload.Role, + Actor: payload.Actor, + }) + w.WriteHeader(http.StatusNoContent) + }) +} + +// requestIP mirrors cmd/gitfed-web's clientIP: behind the same Traefik +// ingress, the real client address arrives in X-Real-IP or as the last +// (proxy-appended, so unspoofable) hop of X-Forwarded-For. +func requestIP(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 +}
internal/federation/resolver.go
diff --git a/internal/federation/resolver.go b/internal/federation/resolver.go index 43b228e..292fac7 100644 --- a/internal/federation/resolver.go +++ b/internal/federation/resolver.go @@ -23,6 +23,12 @@ func NewResolver(s *store.Store, localDomain string, insecureHTTP bool) *Resolve return &Resolver{store: s, localDomain: localDomain, insecure: insecureHTTP} } +// Insecure reports whether this resolver was configured for dev-only plain +// HTTP federation — callers outside this package that also make their own +// federation HTTP calls (currently just SendNotify) need this to match the +// same scheme/guard behavior as Fetch. +func (r *Resolver) Insecure() bool { return r.insecure } + // Discovering a domain we've never seen means an outbound HTTP request to a // host named by whoever is granting the collaborator — capping how often // that can happen bounds the abuse/SSRF-probing surface, per DESIGN.md §9
internal/gitexec/gitexec.go
diff --git a/internal/gitexec/gitexec.go b/internal/gitexec/gitexec.go index 2482b25..7f2b538 100644 --- a/internal/gitexec/gitexec.go +++ b/internal/gitexec/gitexec.go @@ -12,7 +12,9 @@ import ( "path/filepath" "regexp" "sort" + "strconv" "strings" + "time" ) // Verb identifies which git service was requested. @@ -264,3 +266,54 @@ func ListTags(repoPath string) ([]string, error) { } return tags, nil } + +// Commit is one entry in a repo's history, as shown by ListCommits. +type Commit struct { + Hash string `json:"hash"` + ShortHash string `json:"short_hash"` + Author string `json:"author"` + Email string `json:"email"` + Date time.Time `json:"date"` + Subject string `json:"subject"` // first line of the commit message only +} + +// commitFieldSep and commitRecordSep are ASCII unit/record separators +// (0x1f/0x1e) — control characters that can't appear in a commit's own +// metadata, so they safely delimit fields/records no matter what a commit +// subject line itself contains (unlike a printable character such as "|"). +const commitFieldSep, commitRecordSep = "\x1f", "\x1e" + +// ListCommits returns up to limit commits reachable from the repo's default +// branch (see resolveDefaultRef), most recent first. found is false (nil +// error) for a repo with no commits yet. +func ListCommits(repoPath string, limit int) (commits []Commit, found bool, err error) { + ref, ok := resolveDefaultRef(repoPath) + if !ok { + return nil, false, nil + } + + format := strings.Join([]string{"%H", "%h", "%an", "%ae", "%aI", "%s"}, commitFieldSep) + commitRecordSep + cmd := exec.Command("git", "--git-dir="+repoPath, "log", "--max-count="+strconv.Itoa(limit), "--format="+format, ref) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return nil, false, fmt.Errorf("gitexec: log %s: %w: %s", ref, err, stderr.String()) + } + + for _, record := range strings.Split(stdout.String(), commitRecordSep) { + record = strings.TrimPrefix(record, "\n") + if record == "" { + continue + } + f := strings.Split(record, commitFieldSep) + if len(f) != 6 { + continue + } + date, _ := time.Parse(time.RFC3339, f[4]) + commits = append(commits, Commit{ + Hash: f[0], ShortHash: f[1], Author: f[2], Email: f[3], Date: date, Subject: f[5], + }) + } + return commits, true, nil +}
internal/i18n/strings_en.go
diff --git a/internal/i18n/strings_en.go b/internal/i18n/strings_en.go index fb91dbd..501c009 100644 --- a/internal/i18n/strings_en.go +++ b/internal/i18n/strings_en.go @@ -12,6 +12,7 @@ var en = map[string]string{ "nav.settings": "Settings", "nav.admin": "Admin", "nav.logout": "Log out", + "nav.notifications": "Notifications", "footer.changelog": "gitfed %s", // ---------- explore ---------- @@ -129,6 +130,28 @@ var en = map[string]string{ "dashboard.msg_bad_namespace": "you can only create repositories in your own namespace", "dashboard.msg_quota": "you have reached the maximum number of repositories", + // ---------- pinned repos ---------- + "dashboard.pinned_title": "Pinned elsewhere", + "dashboard.pinned_note": "Bookmarks to repos on other instances — a link, not a credential. Works for public repos with no login; a private one just needs a session on that instance.", + "dashboard.pin_add": "Pin a repo", + "dashboard.pin_input_label": "Repo URL or domain/owner/repo", + "dashboard.pin_remove": "Remove", + "dashboard.pins_empty": "Nothing pinned yet.", + "dashboard.msg_pin_invalid": "couldn't make sense of that as a domain and repo", + "dashboard.msg_pin_quota": "you've hit the maximum number of pins", + "dashboard.msg_pinned": "pinned %s", + + // ---------- notifications ---------- + "notif.title": "Notifications", + "notif.note": "Another instance telling you that you were granted access somewhere — informational only. Accepting just pins the link; it grants nothing by itself.", + "notif.granted_by": "granted by", + "notif.accept": "Accept", + "notif.dismiss": "Dismiss", + "notif.status_accepted": "accepted", + "notif.status_dismissed": "dismissed", + "notif.empty": "No notifications.", + "notif.msg_accepted": "pinned to your dashboard", + // ---------- settings ---------- "settings.title": "Settings", "settings.tab_profile": "Profile", @@ -217,31 +240,35 @@ var en = map[string]string{ "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", + "repo.owner": "owner", + "repo.copy_clone_url": "Copy clone URL", + "repo.read_only": "read-only", + "repo.https_readonly_hint": "Anonymous clone/fetch only — pushing over HTTPS is never possible, even for a public repo. Push always goes over SSH.", + "repo.empty": "This repository is empty.", + "repo.directory": "directory", + "repo.file": "file", + "repo.nothing_here": "Nothing here.", + "repo.tags": "Tags", + "repo.commits_title": "Commits", + "repo.commits_empty": "No commits yet.", + "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 index 9896e4a..276b673 100644 --- a/internal/i18n/strings_fr.go +++ b/internal/i18n/strings_fr.go @@ -12,6 +12,7 @@ var fr = map[string]string{ "nav.settings": "Paramètres", "nav.admin": "Administration", "nav.logout": "Se déconnecter", + "nav.notifications": "Notifications", "footer.changelog": "gitfed %s", // ---------- explore ---------- @@ -129,6 +130,28 @@ var fr = map[string]string{ "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", + // ---------- dépôts épinglés ---------- + "dashboard.pinned_title": "Épinglés ailleurs", + "dashboard.pinned_note": "Des liens vers des dépôts sur d'autres instances — un lien, pas un accès. Fonctionne sans rien pour un dépôt public ; un privé demande juste une session sur cette instance-là.", + "dashboard.pin_add": "Épingler un dépôt", + "dashboard.pin_input_label": "URL du dépôt ou domaine/propriétaire/dépôt", + "dashboard.pin_remove": "Retirer", + "dashboard.pins_empty": "Rien d'épinglé pour le moment.", + "dashboard.msg_pin_invalid": "impossible d'en tirer un domaine et un dépôt", + "dashboard.msg_pin_quota": "vous avez atteint le nombre maximum d'épingles", + "dashboard.msg_pinned": "%s épinglé", + + // ---------- notifications ---------- + "notif.title": "Notifications", + "notif.note": "Une autre instance vous informe qu'un accès vous a été accordé chez elle — purement informatif. Accepter épingle juste le lien ; ça n'accorde rien par soi-même.", + "notif.granted_by": "accordé par", + "notif.accept": "Accepter", + "notif.dismiss": "Ignorer", + "notif.status_accepted": "accepté", + "notif.status_dismissed": "ignoré", + "notif.empty": "Aucune notification.", + "notif.msg_accepted": "épinglé sur votre tableau de bord", + // ---------- settings ---------- "settings.title": "Paramètres", "settings.tab_profile": "Profil", @@ -217,31 +240,35 @@ var fr = map[string]string{ "admin.audit_note": "Affichage des 200 événements les plus récents.", // ---------- repo ---------- - "repo.owner": "propriétaire", - "repo.copy_clone_url": "Copier l'URL de clonage", - "repo.empty": "Ce dépôt est vide.", - "repo.directory": "dossier", - "repo.file": "fichier", - "repo.nothing_here": "Rien ici.", - "repo.tags": "Tags", - "repo.license": "Licence", - "repo.binary_file": "Fichier binaire — non affiché.", - "repo.settings_title": "paramètres", - "repo.visibility_topics": "Visibilité et sujets", - "repo.public_desc": "Public (lisible par tout principal authentifié)", - "repo.topics_label": "Sujets (séparés par des virgules)", - "repo.save": "Enregistrer", - "repo.col_principal": "Principal", - "repo.col_role": "Rôle", - "repo.revoke": "Révoquer", - "repo.grant_collaborator": "Ajouter un collaborateur", - "repo.grant": "Accorder", - "repo.grant_note": "Accorder un accès à un domaine encore inconnu de cette instance déclenche la découverte fédérée de la CA de ce domaine — un administrateur de l'instance doit l'approuver dans Administration → Confiance fédérée (politique de liste blanche).", - "repo.confirm_delete": "Supprimer", - "repo.danger_zone": "Zone de danger", - "repo.delete_record": "Supprimer l'entrée du dépôt", - "repo.msg_saved": "paramètres enregistrés", - "repo.msg_granted": "%s ajouté avec le rôle %s", - "repo.msg_revoked": "%s révoqué", - "repo.msg_deleted": "%s supprimé", + "repo.owner": "propriétaire", + "repo.copy_clone_url": "Copier l'URL de clonage", + "repo.read_only": "lecture seule", + "repo.https_readonly_hint": "Clone/fetch anonyme uniquement — impossible de pousser en HTTPS, même sur un dépôt public. Le push passe toujours par SSH.", + "repo.empty": "Ce dépôt est vide.", + "repo.directory": "dossier", + "repo.file": "fichier", + "repo.nothing_here": "Rien ici.", + "repo.tags": "Tags", + "repo.commits_title": "Commits", + "repo.commits_empty": "Aucun commit pour le moment.", + "repo.license": "Licence", + "repo.binary_file": "Fichier binaire — non affiché.", + "repo.settings_title": "paramètres", + "repo.visibility_topics": "Visibilité et sujets", + "repo.public_desc": "Public (lisible par tout principal authentifié)", + "repo.topics_label": "Sujets (séparés par des virgules)", + "repo.save": "Enregistrer", + "repo.col_principal": "Principal", + "repo.col_role": "Rôle", + "repo.revoke": "Révoquer", + "repo.grant_collaborator": "Ajouter un collaborateur", + "repo.grant": "Accorder", + "repo.grant_note": "Accorder un accès à un domaine encore inconnu de cette instance déclenche la découverte fédérée de la CA de ce domaine — un administrateur de l'instance doit l'approuver dans Administration → Confiance fédérée (politique de liste blanche).", + "repo.confirm_delete": "Supprimer", + "repo.danger_zone": "Zone de danger", + "repo.delete_record": "Supprimer l'entrée du dépôt", + "repo.msg_saved": "paramètres enregistrés", + "repo.msg_granted": "%s ajouté avec le rôle %s", + "repo.msg_revoked": "%s révoqué", + "repo.msg_deleted": "%s supprimé", }
internal/opsconnect/connect.go
diff --git a/internal/opsconnect/connect.go b/internal/opsconnect/connect.go index 92328cb..644e665 100644 --- a/internal/opsconnect/connect.go +++ b/internal/opsconnect/connect.go @@ -10,6 +10,7 @@ import ( "gitfed/internal/admin" "gitfed/internal/adminrpc" + "gitfed/internal/ca" "gitfed/internal/config" "gitfed/internal/federation" "gitfed/internal/store" @@ -30,6 +31,11 @@ func Connect(cfg config.Config) (ops admin.Ops, mode string, closeFn func(), err return nil, "", nil, fmt.Errorf("no running server on %s, and could not open store directly: %w", cfg.AdminSocketPath(), err) } resolver := federation.NewResolver(st, cfg.Domain, cfg.InsecureFederation) - a := admin.New(st, resolver, cfg.Domain, cfg.ReposDir) + localCA, err := ca.LoadOrCreate(cfg.CADir()) + if err != nil { + st.Close() + return nil, "", nil, fmt.Errorf("load CA: %w", err) + } + a := admin.New(st, resolver, cfg.Domain, cfg.ReposDir, localCA) return a, "offline (server not running, editing store directly)", func() { st.Close() }, nil }
internal/store/notifications.go
diff --git a/internal/store/notifications.go b/internal/store/notifications.go new file mode 100644 index 0000000..264fd28 --- /dev/null +++ b/internal/store/notifications.go @@ -0,0 +1,124 @@ +package store + +import ( + "crypto/rand" + "encoding/base64" + "sort" + "time" + + bolt "go.etcd.io/bbolt" +) + +type NotificationStatus string + +const ( + NotificationPending NotificationStatus = "pending" + NotificationAccepted NotificationStatus = "accepted" + NotificationDismissed NotificationStatus = "dismissed" +) + +// Notification is a purely advisory record: "some other instance says you +// were granted access to one of its repos." It is never itself a +// credential — accepting one only adds a PinnedRepo bookmark; the access it +// describes is (or isn't) real entirely independently of this record, +// enforced by the granting instance's own ACL when the repo is actually +// used. See internal/federation's notify sender/receiver for how these get +// here and what's verified before one is ever created. +type Notification struct { + ID string `json:"id"` + Principal string `json:"principal"` // local recipient + FromDomain string `json:"from_domain"` + Repo string `json:"repo"` + Role string `json:"role"` + Actor string `json:"actor"` // who granted it, e.g. "alice@chez-moi.fr" + Status NotificationStatus `json:"status"` + ReceivedAt time.Time `json:"received_at"` +} + +func notifKey(principal, id string) string { return principal + "\x00" + id } + +// CreateNotification records n, generating an ID if it doesn't have one. +// If a pending notification already exists for the same +// (Principal, FromDomain, Repo, Actor) — the common case being the same +// grant notified twice, or a role change re-notified — its timestamp and +// role are refreshed instead of creating a duplicate entry. +func (s *Store) CreateNotification(n Notification) error { + return s.db.Update(func(tx *bolt.Tx) error { + existing, err := listJSONPrefix[Notification](tx, bucketNotifs, n.Principal+"\x00") + if err != nil { + return err + } + for _, e := range existing { + if e.Status == NotificationPending && e.FromDomain == n.FromDomain && e.Repo == n.Repo && e.Actor == n.Actor { + e.Role = n.Role + e.ReceivedAt = time.Now().UTC() + return putJSON(tx, bucketNotifs, notifKey(e.Principal, e.ID), e) + } + } + if n.ID == "" { + id, err := randomToken() + if err != nil { + return err + } + n.ID = id + } + n.Status = NotificationPending + n.ReceivedAt = time.Now().UTC() + return putJSON(tx, bucketNotifs, notifKey(n.Principal, n.ID), n) + }) +} + +// ListNotifications returns all of principal's notifications, newest first. +func (s *Store) ListNotifications(principal string) ([]Notification, error) { + var out []Notification + err := s.db.View(func(tx *bolt.Tx) error { + var err error + out, err = listJSONPrefix[Notification](tx, bucketNotifs, principal+"\x00") + return err + }) + sort.Slice(out, func(i, j int) bool { return out[i].ReceivedAt.After(out[j].ReceivedAt) }) + return out, err +} + +// CountPendingNotifications is a small helper for the nav badge, so it +// doesn't need to unmarshal and sort the full list just for a count. +func (s *Store) CountPendingNotifications(principal string) (int, error) { + all, err := s.ListNotifications(principal) + if err != nil { + return 0, err + } + n := 0 + for _, e := range all { + if e.Status == NotificationPending { + n++ + } + } + return n, nil +} + +// SetNotificationStatus updates one of principal's own notifications. It's a +// no-op (not an error) if the ID doesn't belong to principal or doesn't +// exist, so a stale/tampered ID in a form submission can't probe for other +// users' notification IDs. +func (s *Store) SetNotificationStatus(principal, id string, status NotificationStatus) error { + return s.db.Update(func(tx *bolt.Tx) error { + var n Notification + key := notifKey(principal, id) + if err := getJSON(tx, bucketNotifs, key, &n); err != nil { + if err == ErrNotFound { + return nil + } + return err + } + n.Status = status + return putJSON(tx, bucketNotifs, key, n) + }) +} + +func randomToken() (string, error) { + buf := make([]byte, 16) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buf), nil +}
internal/store/pins.go
diff --git a/internal/store/pins.go b/internal/store/pins.go new file mode 100644 index 0000000..0c5876a --- /dev/null +++ b/internal/store/pins.go @@ -0,0 +1,49 @@ +package store + +import ( + "time" + + bolt "go.etcd.io/bbolt" +) + +// PinnedRepo is a purely local bookmark: a principal's own note that a repo +// on some other (or even the same) instance is worth a link on their +// dashboard. It grants no access by itself — it's a convenience, not a +// credential. Pinning a private repo you don't actually have access to just +// gives you a link that 404s. +type PinnedRepo struct { + Principal string `json:"principal"` // the local account that pinned it + Domain string `json:"domain"` // remote instance domain, e.g. "chez-moi.fr" + Repo string `json:"repo"` // repo name on that instance, e.g. "alice/mon-projet" + Label string `json:"label,omitempty"` + AddedAt time.Time `json:"added_at"` +} + +func pinKey(principal, domain, repo string) string { + return principal + "\x00" + domain + "/" + repo +} + +// PinRepo records (or updates the label of) a bookmark for principal. +func (s *Store) PinRepo(principal, domain, repo, label string) error { + p := PinnedRepo{Principal: principal, Domain: domain, Repo: repo, Label: label, AddedAt: time.Now().UTC()} + return s.db.Update(func(tx *bolt.Tx) error { + return putJSON(tx, bucketPins, pinKey(principal, domain, repo), p) + }) +} + +func (s *Store) UnpinRepo(principal, domain, repo string) error { + return s.db.Update(func(tx *bolt.Tx) error { + return deleteKey(tx, bucketPins, pinKey(principal, domain, repo)) + }) +} + +// ListPinnedRepos returns principal's own bookmarks, oldest first. +func (s *Store) ListPinnedRepos(principal string) ([]PinnedRepo, error) { + var out []PinnedRepo + err := s.db.View(func(tx *bolt.Tx) error { + var err error + out, err = listJSONPrefix[PinnedRepo](tx, bucketPins, principal+"\x00") + return err + }) + return out, err +}
internal/store/store.go
diff --git a/internal/store/store.go b/internal/store/store.go index 71595c9..5e0b4df 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -3,6 +3,7 @@ package store import ( + "bytes" "encoding/json" "errors" "fmt" @@ -23,6 +24,8 @@ var ( bucketAuth = []byte("auth") // username -> password hash, kept apart from User so it's never returned by ListUsers/GetUser bucketSessions = []byte("sessions") // token -> Session, for web login bucketRevoked = []byte("revoked") // revoked principals / cert keys, consulted during SSH cert auth + bucketPins = []byte("pins") // principal -> bookmarked remote repos, self-service only + bucketNotifs = []byte("notifs") // principal -> inbound federated notifications ) type Store struct { @@ -37,7 +40,7 @@ func Open(path string) (*Store, error) { return nil, fmt.Errorf("store: open %s: %w", path, err) } err = db.Update(func(tx *bolt.Tx) error { - for _, b := range [][]byte{bucketUsers, bucketRepos, bucketACL, bucketTrust, bucketMeta, bucketAudit, bucketAuth, bucketSessions, bucketRevoked} { + for _, b := range [][]byte{bucketUsers, bucketRepos, bucketACL, bucketTrust, bucketMeta, bucketAudit, bucketAuth, bucketSessions, bucketRevoked, bucketPins, bucketNotifs} { if _, err := tx.CreateBucketIfNotExists(b); err != nil { return err } @@ -87,3 +90,20 @@ func listJSON[T any](tx *bolt.Tx, bucket []byte) ([]T, error) { } return out, nil } + +// listJSONPrefix is listJSON scoped to keys starting with prefix — used for +// buckets keyed "<principal>\x00<rest>" so a given user's own records (pins, +// notifications) can be listed without scanning every other user's. +func listJSONPrefix[T any](tx *bolt.Tx, bucket []byte, prefix string) ([]T, error) { + var out []T + c := tx.Bucket(bucket).Cursor() + p := []byte(prefix) + for k, v := c.Seek(p); k != nil && bytes.HasPrefix(k, p); k, v = c.Next() { + var item T + if err := json.Unmarshal(v, &item); err != nil { + return nil, err + } + out = append(out, item) + } + return out, nil +}