Commit 2867b44a48

2867b44a48ea8afa6b7c21d5e1d69272a7b235fa

parent: 70dc0648f9

Verified · cmc ci/build: success ci/sonar: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-06 14:55 UTC

web, wiki: download the account bundle from settings

account export was CLI-only. /settings/export runs the same read-only
command and serves its output as an attachment.

The SSH-only block on the settings page listed 'gitbay auth export',
which is not a command; the export section replaces that line.

Closes #166

Layout: unified · split

.gitbay/wiki/Parity.org +1 −1
@@ -232,7 +232,7 @@ client has no use for one (krz/gitbay#57).
232232| dashboard aggregate | yes | yes | yes |
233233| notification inbox | yes | yes | no |
234234| API token mint | yes | no | no |
235| account export bundle | yes | no | no |
235| account export bundle | yes | yes | no |
236236| profile set | yes | no | no |
237237| request a login link | n/a | yes | no |
238238
e2e/accountweb_test.go +26
@@ -2,6 +2,7 @@ package e2e
22
33import (
44 "encoding/json"
5 "io"
56 "net/url"
67 "os"
78 "strings"
@@ -96,6 +97,31 @@ func TestAccountSettingsWeb(t *testing.T) {
9697 if strings.Contains(body, `value="token-mint"`) {
9798 t.Error("token minting exposed on the web")
9899 }
100
101 // The account bundle downloads as an attachment, carrying what
102 // "account export" writes (#166).
103 resp, err := browser.Get(inst.base() + "/settings/export")
104 if err != nil {
105 t.Fatal(err)
106 }
107 defer resp.Body.Close()
108 bundle, _ := io.ReadAll(resp.Body)
109 if resp.StatusCode != 200 {
110 t.Fatalf("export: %d", resp.StatusCode)
111 }
112 if !strings.Contains(resp.Header.Get("Content-Disposition"), `filename="alice.bundle"`) {
113 t.Errorf("export is not an attachment: %q", resp.Header.Get("Content-Disposition"))
114 }
115 var got struct {
116 Bundle string `json:"bundle"`
117 Username string `json:"username"`
118 }
119 if err := json.Unmarshal(bundle, &got); err != nil {
120 t.Fatalf("bundle is not JSON: %v\n%s", err, bundle)
121 }
122 if got.Username != "alice" || !strings.HasPrefix(got.Bundle, "gitbay-account/") {
123 t.Errorf("wrong bundle: %s", bundle)
124 }
99125}
100126
101127// gitScopedFingerprint pulls the fingerprint of the git-scoped key out of
internal/httpd/account.go +19
@@ -2,11 +2,14 @@ package httpd
22
33import (
44 "encoding/json"
5 "fmt"
6 "io"
57 "net/http"
68 "net/url"
79 "strings"
810
911 "gitbay.org/gitbay/internal/control"
12 "gitbay.org/gitbay/internal/protocol"
1013 "gitbay.org/gitbay/internal/store"
1114)
1215
@@ -65,6 +68,22 @@ func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.Use
6568 s.takeFlash(w, r), r.URL.Query().Get("m")})
6669}
6770
71// accountExport hands the browser the same bundle `account export`
72// writes. The command is ReadOnly, so a GET is enough; the response is an
73// attachment rather than a page because the bundle is a file to keep.
74func (s *Server) accountExport(w http.ResponseWriter, r *http.Request, u store.User) {
75 out, msg, code := s.runControlCode(u, []string{"account", "export"})
76 if code != protocol.ExitOK {
77 s.setFlash(w, msg)
78 http.Redirect(w, r, "/settings", http.StatusSeeOther)
79 return
80 }
81 w.Header().Set("Content-Type", "application/json")
82 w.Header().Set("X-Content-Type-Options", "nosniff")
83 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", u.Username+".bundle"))
84 io.WriteString(w, out)
85}
86
6887// profileLinksText turns a profile's links into the form the textarea
6988// shows and reads back: one per line, "label|url" when there is a label
7089// and the bare url otherwise.
internal/httpd/routes.go +1
@@ -104,6 +104,7 @@ func (s *Server) Routes() []Route {
104104 Handler: s.checkOrigin(s.logout)},
105105 Route{Method: "GET", Pattern: "/new", Handler: s.requireUser(s.newRepoForm)},
106106 Route{Method: "GET", Pattern: "/settings", Handler: s.requireUser(s.accountForm)},
107 Route{Method: "GET", Pattern: "/settings/export", Handler: s.requireUser(s.accountExport)},
107108 Route{Method: "GET", Pattern: "/notifications", Handler: s.requireUser(s.notifications)},
108109 Route{Method: "POST", Pattern: "/notifications", Mutating: true,
109110 Handler: s.checkOrigin(s.requireUser(s.notificationsRead))},
internal/web/templates/account.html +6 −1
@@ -96,11 +96,16 @@ account, and where notifications go.</p>
9696 </form>
9797</details>
9898
99<h2>Export</h2>
100<p class="meta">Your profile, repositories, issues and merge requests as one
101JSON bundle, the same one <code>gitbay account export</code> writes. Keys are
102never included; a replayed bundle's emails arrive unverified.</p>
103<p><a href="/settings/export">Download bundle</a></p>
104
99105<h2>On SSH only</h2>
100106<p class="meta">Anything whose input is a credential stays on the command line,
101107where it can be piped instead of pasted:</p>
102108<pre class="message">gitbay auth token mint --name laptop # API tokens
103gitbay auth export &gt; account.bundle # your account, portable
104109gitbay admin ... # instance administration</pre>
105110<p class="meta">All of the above works from stock OpenSSH too:
106111<code>ssh git@{{.Host}} auth whoami</code>.</p>