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).
232| dashboard aggregate | yes | yes | yes | 232| dashboard aggregate | yes | yes | yes |
233| notification inbox | yes | yes | no | 233| notification inbox | yes | yes | no |
234| API token mint | yes | no | no | 234| API token mint | yes | no | no |
235| account export bundle | yes | no | no | 235| account export bundle | yes | yes | no |
236| profile set | yes | no | no | 236| profile set | yes | no | no |
237| request a login link | n/a | yes | no | 237| request a login link | n/a | yes | no |
238 238
e2e/accountweb_test.go +26
@@ -2,6 +2,7 @@ package e2e
2 2
3import ( 3import (
4 "encoding/json" 4 "encoding/json"
5 "io"
5 "net/url" 6 "net/url"
6 "os" 7 "os"
7 "strings" 8 "strings"
@@ -96,6 +97,31 @@ func TestAccountSettingsWeb(t *testing.T) {
96 if strings.Contains(body, `value="token-mint"`) { 97 if strings.Contains(body, `value="token-mint"`) {
97 t.Error("token minting exposed on the web") 98 t.Error("token minting exposed on the web")
98 } 99 }
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 }
99} 125}
100 126
101// gitScopedFingerprint pulls the fingerprint of the git-scoped key out of 127// gitScopedFingerprint pulls the fingerprint of the git-scoped key out of
internal/httpd/account.go +19
@@ -2,11 +2,14 @@ package httpd
2 2
3import ( 3import (
4 "encoding/json" 4 "encoding/json"
5 "fmt"
6 "io"
5 "net/http" 7 "net/http"
6 "net/url" 8 "net/url"
7 "strings" 9 "strings"
8 10
9 "gitbay.org/gitbay/internal/control" 11 "gitbay.org/gitbay/internal/control"
12 "gitbay.org/gitbay/internal/protocol"
10 "gitbay.org/gitbay/internal/store" 13 "gitbay.org/gitbay/internal/store"
11) 14)
12 15
@@ -65,6 +68,22 @@ func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.Use
65 s.takeFlash(w, r), r.URL.Query().Get("m")}) 68 s.takeFlash(w, r), r.URL.Query().Get("m")})
66} 69}
67 70
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
68// profileLinksText turns a profile's links into the form the textarea 87// profileLinksText turns a profile's links into the form the textarea
69// shows and reads back: one per line, "label|url" when there is a label 88// shows and reads back: one per line, "label|url" when there is a label
70// and the bare url otherwise. 89// and the bare url otherwise.
internal/httpd/routes.go +1
@@ -104,6 +104,7 @@ func (s *Server) Routes() []Route {
104 Handler: s.checkOrigin(s.logout)}, 104 Handler: s.checkOrigin(s.logout)},
105 Route{Method: "GET", Pattern: "/new", Handler: s.requireUser(s.newRepoForm)}, 105 Route{Method: "GET", Pattern: "/new", Handler: s.requireUser(s.newRepoForm)},
106 Route{Method: "GET", Pattern: "/settings", Handler: s.requireUser(s.accountForm)}, 106 Route{Method: "GET", Pattern: "/settings", Handler: s.requireUser(s.accountForm)},
107 Route{Method: "GET", Pattern: "/settings/export", Handler: s.requireUser(s.accountExport)},
107 Route{Method: "GET", Pattern: "/notifications", Handler: s.requireUser(s.notifications)}, 108 Route{Method: "GET", Pattern: "/notifications", Handler: s.requireUser(s.notifications)},
108 Route{Method: "POST", Pattern: "/notifications", Mutating: true, 109 Route{Method: "POST", Pattern: "/notifications", Mutating: true,
109 Handler: s.checkOrigin(s.requireUser(s.notificationsRead))}, 110 Handler: s.checkOrigin(s.requireUser(s.notificationsRead))},
internal/web/templates/account.html +6 −1
@@ -96,11 +96,16 @@ account, and where notifications go.</p>
96 </form> 96 </form>
97</details> 97</details>
98 98
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
99<h2>On SSH only</h2> 105<h2>On SSH only</h2>
100<p class="meta">Anything whose input is a credential stays on the command line, 106<p class="meta">Anything whose input is a credential stays on the command line,
101where it can be piped instead of pasted:</p> 107where it can be piped instead of pasted:</p>
102<pre class="message">gitbay auth token mint --name laptop # API tokens 108<pre class="message">gitbay auth token mint --name laptop # API tokens
103gitbay auth export &gt; account.bundle # your account, portable
104gitbay admin ... # instance administration</pre> 109gitbay admin ... # instance administration</pre>
105<p class="meta">All of the above works from stock OpenSSH too: 110<p class="meta">All of the above works from stock OpenSSH too:
106<code>ssh git@{{.Host}} auth whoami</code>.</p> 111<code>ssh git@{{.Host}} auth whoami</code>.</p>