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 | |
| 3 | import ( |
3 | import ( |
| 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 | |
| 3 | import ( |
3 | import ( |
| 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. |
| |
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 | // 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 |
| |
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 | <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, |
| 101 | where it can be piped instead of pasted:</p> |
107 | where 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 |
| 103 | gitbay auth export > account.bundle # your account, portable |
| |
| 104 | gitbay admin ... # instance administration</pre> |
109 | gitbay 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> |