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 | 232 | | dashboard aggregate | yes | yes | yes | |
| 233 | 233 | | notification inbox | yes | yes | no | |
| 234 | 234 | | API token mint | yes | no | no | |
| 235 | | | account export bundle | yes | no | no | |
| 235 | | account export bundle | yes | yes | no | |
| 236 | 236 | | profile set | yes | no | no | |
| 237 | 237 | | request a login link | n/a | yes | no | |
| 238 | 238 | |
e2e/accountweb_test.go
+26
| @@ -2,6 +2,7 @@ package e2e |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 4 | "encoding/json" |
| 5 | "io" |
| 5 | 6 | "net/url" |
| 6 | 7 | "os" |
| 7 | 8 | "strings" |
| @@ -96,6 +97,31 @@ func TestAccountSettingsWeb(t *testing.T) { |
| 96 | 97 | if strings.Contains(body, `value="token-mint"`) { |
| 97 | 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 | 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 | |
| 3 | 3 | import ( |
| 4 | 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "io" |
| 5 | 7 | "net/http" |
| 6 | 8 | "net/url" |
| 7 | 9 | "strings" |
| 8 | 10 | |
| 9 | 11 | "gitbay.org/gitbay/internal/control" |
| 12 | "gitbay.org/gitbay/internal/protocol" |
| 10 | 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 | 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. |
| 74 | func (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 | 87 | // profileLinksText turns a profile's links into the form the textarea |
| 69 | 88 | // shows and reads back: one per line, "label|url" when there is a label |
| 70 | 89 | // and the bare url otherwise. |
internal/httpd/routes.go
+1
| @@ -104,6 +104,7 @@ func (s *Server) Routes() []Route { |
| 104 | 104 | Handler: s.checkOrigin(s.logout)}, |
| 105 | 105 | Route{Method: "GET", Pattern: "/new", Handler: s.requireUser(s.newRepoForm)}, |
| 106 | 106 | Route{Method: "GET", Pattern: "/settings", Handler: s.requireUser(s.accountForm)}, |
| 107 | Route{Method: "GET", Pattern: "/settings/export", Handler: s.requireUser(s.accountExport)}, |
| 107 | 108 | Route{Method: "GET", Pattern: "/notifications", Handler: s.requireUser(s.notifications)}, |
| 108 | 109 | Route{Method: "POST", Pattern: "/notifications", Mutating: true, |
| 109 | 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 | 96 | </form> |
| 97 | 97 | </details> |
| 98 | 98 | |
| 99 | <h2>Export</h2> |
| 100 | <p class="meta">Your profile, repositories, issues and merge requests as one |
| 101 | JSON bundle, the same one <code>gitbay account export</code> writes. Keys are |
| 102 | never included; a replayed bundle's emails arrive unverified.</p> |
| 103 | <p><a href="/settings/export">Download bundle</a></p> |
| 104 | |
| 99 | 105 | <h2>On SSH only</h2> |
| 100 | 106 | <p class="meta">Anything whose input is a credential stays on the command line, |
| 101 | 107 | where it can be piped instead of pasted:</p> |
| 102 | 108 | <pre class="message">gitbay auth token mint --name laptop # API tokens |
| 103 | | gitbay auth export > account.bundle # your account, portable |
| 104 | 109 | gitbay admin ... # instance administration</pre> |
| 105 | 110 | <p class="meta">All of the above works from stock OpenSSH too: |
| 106 | 111 | <code>ssh git@{{.Host}} auth whoami</code>.</p> |