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 := ¬ifyLimiter{}
+ 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
+}