~gitpocket/gitpocket

1

feat(basin): ssh signature verification badges

commit
01b3aeb2e07c85beb16397502eddb8f44bd1e7a0 browse tree
parent
df2b965cfb1fa514eb0afafcb67b2d2c94345994
author
james <j@cpl0.zip>
signature
none
date
2026-10-01 19:32 UTC

10 files changed, +94 −116

git.go modified

@@ -4,6 +4,7 @@ import (
44 "bytes"
55 "context"
66 "errors"
7+ "os"
78 "os/exec"
89 "path"
910 "strconv"
@@ -17,15 +18,19 @@ type Git struct {
1718 }
1819
1920 type Commit struct {
20- Hash string
21- Author string
22- Email string
23- Time time.Time
24- Subject string
25- Body string
26- Parents []string
21+ Hash string
22+ Author string
23+ Email string
24+ Time time.Time
25+ Subject string
26+ Body string
27+ Parents []string
28+ Signature string
29+ Key string
2730 }
2831
32+var signersFile string
33+
2934 type Entry struct {
3035 Mode string
3136 Type string
@@ -72,7 +77,10 @@ func (g Git) runEnv(env []string, stdin []byte, args ...string) ([]byte, error)
7277 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
7378 defer cancel()
7479 cmd := exec.CommandContext(ctx, "git", append([]string{"-C", g.Dir}, args...)...)
75- cmd.Env = append([]string{"GIT_CONFIG_NOSYSTEM=1", "HOME=/nonexistent", "LC_ALL=C.UTF-8"}, env...)
80+ cmd.Env = append([]string{"GIT_CONFIG_NOSYSTEM=1", "HOME=/nonexistent", "LC_ALL=C.UTF-8", "PATH=" + os.Getenv("PATH")}, env...)
81+ if signersFile != "" {
82+ cmd.Env = append(cmd.Env, "GIT_CONFIG_COUNT=1", "GIT_CONFIG_KEY_0=gpg.ssh.allowedSignersFile", "GIT_CONFIG_VALUE_0="+signersFile)
83+ }
7684 if stdin != nil {
7785 cmd.Stdin = bytes.NewReader(stdin)
7886 }
@@ -190,19 +198,19 @@ func (g Git) Resolve(ref string) (string, error) {
190198 return strings.TrimSpace(string(out)), nil
191199 }
192200
193-const logFormat = "%H%x00%an%x00%ae%x00%at%x00%P%x00%s%x00%b%x00"
201+const logFormat = "%H%x00%an%x00%ae%x00%at%x00%P%x00%s%x00%b%x00%G?%x00%GK%x00"
194202
195203 func parseCommits(out []byte) []Commit {
196204 fields := strings.Split(string(out), "\x00")
197205 var commits []Commit
198- for len(fields) >= 7 {
206+ for len(fields) >= 9 {
199207 t, _ := strconv.ParseInt(fields[3], 10, 64)
200- c := Commit{Hash: fields[0], Author: fields[1], Email: fields[2], Time: time.Unix(t, 0), Subject: fields[5], Body: strings.TrimSpace(fields[6])}
208+ c := Commit{Hash: fields[0], Author: fields[1], Email: fields[2], Time: time.Unix(t, 0), Subject: fields[5], Body: strings.TrimSpace(fields[6]), Signature: fields[7], Key: fields[8]}
201209 if fields[4] != "" {
202210 c.Parents = strings.Split(fields[4], " ")
203211 }
204212 commits = append(commits, c)
205- fields = fields[7:]
213+ fields = fields[9:]
206214 if len(fields) > 0 {
207215 fields[0] = strings.TrimLeft(fields[0], "\n")
208216 }

static/fonts/mono.txt deleted

@@ -1,93 +0,0 @@
1-Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
2-
3-This Font Software is licensed under the SIL Open Font License, Version 1.1.
4-This license is copied below, and is also available with a FAQ at:
5-https://scripts.sil.org/OFL
6-
7-
8------------------------------------------------------------
9-SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10------------------------------------------------------------
11-
12-PREAMBLE
13-The goals of the Open Font License (OFL) are to stimulate worldwide
14-development of collaborative font projects, to support the font creation
15-efforts of academic and linguistic communities, and to provide a free and
16-open framework in which fonts may be shared and improved in partnership
17-with others.
18-
19-The OFL allows the licensed fonts to be used, studied, modified and
20-redistributed freely as long as they are not sold by themselves. The
21-fonts, including any derivative works, can be bundled, embedded,
22-redistributed and/or sold with any software provided that any reserved
23-names are not used by derivative works. The fonts and derivatives,
24-however, cannot be released under any other type of license. The
25-requirement for fonts to remain under this license does not apply
26-to any document created using the fonts or their derivatives.
27-
28-DEFINITIONS
29-"Font Software" refers to the set of files released by the Copyright
30-Holder(s) under this license and clearly marked as such. This may
31-include source files, build scripts and documentation.
32-
33-"Reserved Font Name" refers to any names specified as such after the
34-copyright statement(s).
35-
36-"Original Version" refers to the collection of Font Software components as
37-distributed by the Copyright Holder(s).
38-
39-"Modified Version" refers to any derivative made by adding to, deleting,
40-or substituting -- in part or in whole -- any of the components of the
41-Original Version, by changing formats or by porting the Font Software to a
42-new environment.
43-
44-"Author" refers to any designer, engineer, programmer, technical
45-writer or other person who contributed to the Font Software.
46-
47-PERMISSION & CONDITIONS
48-Permission is hereby granted, free of charge, to any person obtaining
49-a copy of the Font Software, to use, study, copy, merge, embed, modify,
50-redistribute, and sell modified and unmodified copies of the Font
51-Software, subject to the following conditions:
52-
53-1) Neither the Font Software nor any of its individual components,
54-in Original or Modified Versions, may be sold by itself.
55-
56-2) Original or Modified Versions of the Font Software may be bundled,
57-redistributed and/or sold with any software, provided that each copy
58-contains the above copyright notice and this license. These can be
59-included either as stand-alone text files, human-readable headers or
60-in the appropriate machine-readable metadata fields within text or
61-binary files as long as those fields can be easily viewed by the user.
62-
63-3) No Modified Version of the Font Software may use the Reserved Font
64-Name(s) unless explicit written permission is granted by the corresponding
65-Copyright Holder. This restriction only applies to the primary font name as
66-presented to the users.
67-
68-4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69-Software shall not be used to promote, endorse or advertise any
70-Modified Version, except to acknowledge the contribution(s) of the
71-Copyright Holder(s) and the Author(s) or with their explicit written
72-permission.
73-
74-5) The Font Software, modified or unmodified, in part or in whole,
75-must be distributed entirely under this license, and must not be
76-distributed under any other license. The requirement for fonts to
77-remain under this license does not apply to any document created
78-using the Font Software.
79-
80-TERMINATION
81-This license becomes null and void if any of the above conditions are
82-not met.
83-
84-DISCLAIMER
85-THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87-MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88-OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89-COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90-INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91-DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92-FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93-OTHER DEALINGS IN THE FONT SOFTWARE.

static/fonts/mono.woff2 deleted

Binary file.

static/style.css modified

@@ -1,11 +1,10 @@
1-@font-face{font-family:Mono;font-style:normal;font-weight:400;font-display:swap;src:url(/static/fonts/mono.woff2) format('woff2')}
21 :root{--fg:#212529;--muted:#6c757d;--line:#dee2e6;--soft:#f8f9fa;--bar:#ddd;--link:#0640e0;--primary:#0640e0;--add:#1a7f37;--del:#c0392b;--addbg:#e6ffec;--delbg:#ffebe9;--code:#f8f9fa}
32 *{box-sizing:border-box}
43 html{font:15px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","DejaVu Sans",Arial,sans-serif;background:#fff;color:var(--fg)}
54 body{margin:0}
65 a{color:var(--link);text-decoration:none}
76 a:hover{text-decoration:underline}
8-code,pre,.hash,.mode,.blob,.hunk,.code,textarea{font-family:Mono,ui-monospace,Menlo,Consolas,monospace;font-size:13px}
7+code,pre,.hash,.mode,.blob,.hunk,.code,textarea{font-family:"DejaVu Sans Mono",Menlo,Consolas,"Liberation Mono","SF Mono",monospace;font-size:13px}
98 pre{background:var(--code);padding:.6rem .8rem;overflow-x:auto;border:1px solid var(--line)}
109 pre.plain{background:none;border:0;padding:0}
1110 h1{font-size:1.6rem;font-weight:500;margin:0 0 .2rem}
@@ -19,7 +18,7 @@ h4 a{color:var(--fg)}
1918 .tag{font-size:.75rem;border:1px solid var(--line);padding:0 .3rem;color:var(--muted);vertical-align:middle}
2019 .error{color:var(--del);border:1px solid var(--del);padding:.4rem .6rem;background:#fff}
2120 .notice{border:1px solid var(--add);padding:.4rem .6rem;background:var(--addbg)}
22-.inner,nav.site,nav.subtabs,main{max-width:1140px;margin:0 auto;padding-left:1rem;padding-right:1rem}
21+.inner,nav.site,nav.subtabs,main{max-width:1140px;margin:0;padding-left:1rem;padding-right:1rem}
2322 header{margin-bottom:1rem}
2423 nav.site{display:flex;gap:1rem;align-items:center;padding-top:.5rem;padding-bottom:.5rem;flex-wrap:wrap}
2524 nav.site .brand{font-size:1.25rem;color:var(--fg);margin-right:.5rem}
@@ -30,7 +29,7 @@ nav.site .spacer{flex:1}
3029 nav.site form{display:inline}
3130 nav.site button{background:none;border:0;color:var(--muted);cursor:pointer;font:inherit;padding:0}
3231 nav.subtabs{display:flex;gap:0;padding-top:.2rem;border-bottom:1px solid var(--bar);max-width:none}
33-nav.subtabs a:first-child{margin-left:max(1rem,calc((100% - 1140px)/2 + 1rem))}
32+nav.subtabs a:first-child{margin-left:1rem}
3433 nav.subtabs a,nav.tabs a{color:var(--muted);padding:.3rem .9rem}
3534 nav.subtabs a.active{background:#e9ecef;color:var(--fg)}
3635 nav.subtabs a:hover,nav.tabs a:hover{text-decoration:none;color:var(--fg)}
@@ -42,7 +41,7 @@ nav.tabs a{padding:.25rem .9rem}
4241 nav.tabs a.active{background:#fff;color:var(--fg)}
4342 .repohead .desc{margin:0 0 .4rem;color:var(--muted)}
4443 .repohead+main{margin-top:1rem}
45-.repohead+main.file{margin-top:0;padding-top:1.3rem}
44+.repohead+main.file{margin-top:0;padding-top:.6rem}
4645 .summary{margin-top:.2rem;display:grid;grid-template-columns:4fr 1.5fr 2.6fr;gap:2rem;margin-bottom:1.5rem;align-items:start}
4746 .summary .event{background:var(--soft);padding:.4rem .5rem .5rem;margin-bottom:.6rem}
4847 .summary .event .row{display:flex;justify-content:space-between;gap:1rem}
@@ -102,7 +101,7 @@ dl.meta dd{margin:0;overflow-wrap:anywhere}
102101 .log .event{padding:.5rem 0;border-bottom:1px solid var(--line)}
103102 .event .subject{color:var(--fg)}
104103 .event pre.body{margin:.4rem 0 0}
105-.filebar{display:flex;gap:1.2rem;align-items:baseline;border-bottom:1px solid var(--bar);padding:0 1rem .25rem;margin:-1rem -1rem .6rem;flex-wrap:wrap}
104+.filebar{display:flex;gap:1.2rem;align-items:baseline;border-bottom:1px solid var(--bar);padding:0 1rem .25rem;margin:-.6rem -1rem 0;flex-wrap:wrap}
106105 .filebar .spacer{flex:1}
107106 .filebar a.active{color:var(--fg);border-bottom:2px solid var(--fg);padding-bottom:.2rem}
108107 .filebar a{color:var(--muted)}
@@ -126,7 +125,7 @@ table.tree .size{color:var(--muted);text-align:right}
126125 table.tree .last{max-width:30rem;overflow:hidden;text-overflow:ellipsis;width:100%}
127126 table.tree .when{text-align:right}
128127 table.refs td,table.keys td{padding:.3rem 1rem .3rem 0;border-bottom:1px solid var(--line)}
129-.code{margin:0 -1rem}
128+.code{margin:0 -1rem;line-height:1.45}
130129 table.blob,.code table{width:100%}
131130 table.blob td,table.hunk td,.code td{padding:0 .5rem;white-space:pre;vertical-align:top}
132131 td.n{text-align:right;color:var(--muted);user-select:none;width:1%}
@@ -136,7 +135,10 @@ td.l{white-space:pre-wrap;overflow-wrap:anywhere;width:100%}
136135 tr:target td{background:#fff8c5}
137136 .code pre,.code .chroma{background:#fff;border:0;padding:0;margin:0}
138137 .code .lntable{margin-top:0}
139-.code .lntd:first-child{width:1%;user-select:none;border-right:1px solid var(--bar)}
138+.code .lntd:first-child{width:1%;user-select:none;border-right:1px solid var(--bar);padding-top:.4rem}
139+.code .lntd:last-child{padding-top:.4rem}
140+.code .lnlinks{text-decoration:none}
141+.code .lnlinks:hover{text-decoration:underline}
140142 .code .lnt,.code .ln{color:var(--link);margin-right:0;padding:0 .5rem 0 .6rem}
141143 .code .lntd a{color:var(--link);text-decoration:underline}
142144 .code .lntd:last-child{padding-left:.6rem}
@@ -208,3 +210,5 @@ select{font:inherit;padding:.35rem .5rem;border:1px solid #ced4da;background:#ff
208210 .mergeform input,.mergeform textarea{width:100%}
209211 .mergeform .actions{gap:.4rem}
210212 .panel a.btn.block{margin-top:.4rem}
213+.verified{display:inline-block;font-size:.72rem;border:1px solid #1a7f37;color:#1a7f37;border-radius:3px;padding:0 .3rem;vertical-align:middle;margin-left:.3rem}
214+.verified.unknown{border-color:var(--muted);color:var(--muted)}

store.go modified

@@ -336,6 +336,32 @@ func (s *Store) DeleteKey(user, id int64) error {
336336 return err
337337 }
338338
339+func (s *Store) UserByFingerprint(fp string) (User, bool) {
340+ var user int64
341+ if err := s.db.QueryRow(`select user from keys where fingerprint = ?`, fp).Scan(&user); err != nil {
342+ return User{}, false
343+ }
344+ u, err := s.userByID(user)
345+ return u, err == nil
346+}
347+
348+func (s *Store) AllKeys() ([]string, error) {
349+ rows, err := s.db.Query(`select public from keys order by id`)
350+ if err != nil {
351+ return nil, err
352+ }
353+ defer rows.Close()
354+ var keys []string
355+ for rows.Next() {
356+ var k string
357+ if err := rows.Scan(&k); err != nil {
358+ return nil, err
359+ }
360+ keys = append(keys, k)
361+ }
362+ return keys, rows.Err()
363+}
364+
339365 func (s *Store) UserByKey(public string) (User, error) {
340366 pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(public))
341367 if err != nil {

templates/commit.html modified

@@ -7,6 +7,7 @@
77 <dt>commit</dt><dd><code>{{$c.Hash}}</code> <a href="/{{$p.Repo.Path}}/tree/{{$c.Hash}}/">browse tree</a></dd>
88 {{range $c.Parents}}<dt>parent</dt><dd><a href="/{{$p.Repo.Path}}/commit/{{.}}"><code>{{.}}</code></a></dd>{{end}}
99 <dt>author</dt><dd>{{$c.Author}} &lt;{{$c.Email}}&gt;</dd>
10+<dt>signature</dt><dd>{{with signer $c}}<span class="verified">Verified ~{{.}}</span> signed with a key registered by <a href="/~{{.}}">~{{.}}</a>{{else}}{{if and $c.Signature (ne $c.Signature "N")}}<span class="verified unknown">Unverified</span> signed with an unregistered key{{else}}none{{end}}{{end}}</dd>
1011 <dt>date</dt><dd>{{date $c.Time}} UTC</dd>
1112 </dl>
1213 {{if $c.Body}}<pre class="body">{{$c.Body}}</pre>{{end}}

templates/log.html modified

@@ -2,7 +2,8 @@
22 {{$p := .Data.Page}}
33 {{if .Data.Path}}<p class="muted">History of <a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{.Data.Path}}">{{.Data.Path}}</a> on {{$p.Ref}}</p>{{end}}
44 {{if $p.Empty}}<p class="muted">No commits.</p>{{else}}
5-<div class="log">{{range .Data.Commits}}<div class="event"><div><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a> <a class="subject" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{.Subject}}</a></div><div class="muted small">{{.Author}} · <span title="{{date .Time}}">{{ago .Time}}</span></div>{{if .Body}}<pre class="body">{{.Body}}</pre>{{end}}</div>{{end}}</div>
5+<div class="log">{{range .Data.Commits}}<div class="event"><div><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a> <a class="subject" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{.Subject}}</a>{{template "badge" .}}</div><div class="muted small">{{.Author}} · <span title="{{date .Time}}">{{ago .Time}}</span></div>{{if .Body}}<pre class="body">{{.Body}}</pre>{{end}}</div>{{end}}</div>
66 {{with .Data.Next}}<p><a class="btn" href="?from={{.}}{{if $.Data.Path}}&path={{$.Data.Path}}{{end}}">Older ▸</a></p>{{end}}
77 {{end}}
88 {{end}}
9+{{define "badge"}}{{with signer .}}<span class="verified" title="Signed with a key registered by ~{{.}}">Verified ~{{.}}</span>{{else}}{{if and .Signature (ne .Signature "N")}}<span class="verified unknown" title="Signed with an unregistered key">Unverified</span>{{end}}{{end}}{{end}}

templates/pull.html modified

@@ -36,7 +36,7 @@
3636 </div>
3737 {{if .Data.Commits}}
3838 <h3>Commits</h3>
39-<div class="log">{{range .Data.Commits}}<div class="event"><div><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a> <a class="subject" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{.Subject}}</a></div><div class="muted small">{{.Author}} · {{ago .Time}}</div></div>{{end}}</div>
39+<div class="log">{{range .Data.Commits}}<div class="event"><div><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a> <a class="subject" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{.Subject}}</a>{{with signer .}}<span class="verified" title="Signed with a key registered by ~{{.}}">Verified ~{{.}}</span>{{end}}</div><div class="muted small">{{.Author}} · {{ago .Time}}</div></div>{{end}}</div>
4040 {{end}}
4141 {{if .Data.Files}}
4242 <h3>Changes <span class="muted">{{len .Data.Files}} files, <span class="add">+{{.Data.Added}}</span> <span class="del">−{{.Data.Removed}}</span></span></h3>

templates/repo.html modified

@@ -7,7 +7,7 @@ git push -u origin {{$p.Ref}}</pre>
77 {{else}}
88 <div class="summary">
99 <div class="commits">
10-{{range .Data.Commits}}<div class="event"><div class="row"><span><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a> — {{.Author}}</span><a class="when" href="/{{$p.Repo.Path}}/commit/{{.Hash}}" title="{{date .Time}}">{{ago .Time}}</a></div><pre class="subject">{{.Subject}}</pre></div>{{end}}
10+{{range .Data.Commits}}<div class="event"><div class="row"><span><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a> — {{.Author}}{{with signer .}}<span class="verified" title="Signed with a key registered by ~{{.}}">Verified</span>{{end}}</span><a class="when" href="/{{$p.Repo.Path}}/commit/{{.Hash}}" title="{{date .Time}}">{{ago .Time}}</a></div><pre class="subject">{{.Subject}}</pre></div>{{end}}
1111 </div>
1212 <div class="refs">
1313 <h3>refs</h3>

web.go modified

@@ -70,6 +70,7 @@ func newServer(store *Store, root, host string, open bool) *Server {
7070 },
7171 "inc": func(i int) int { return i + 1 },
7272 "perm": perm,
73+ "signer": s.signer,
7374 "join": func(parts []string, upto int) string { return strings.Join(parts[:upto], "/") },
7475 "string": func(b []byte) string { return string(b) },
7576 }
@@ -85,6 +86,7 @@ func newServer(store *Store, root, host string, open bool) *Server {
8586 highlighting.NewHighlighting(highlighting.WithFormatOptions(chromahtml.WithClasses(true)))))
8687 s.policy = bluemonday.UGCPolicy()
8788 s.policy.AllowAttrs("class").Matching(regexp.MustCompile(`^[a-z]{1,8}( [a-z]{1,8})*$`)).OnElements("span", "pre", "code")
89+ s.writeSigners()
8890 return s
8991 }
9092
@@ -287,6 +289,33 @@ func (s *Server) create(w http.ResponseWriter, r *http.Request) {
287289 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
288290 }
289291
292+func (s *Server) signer(c Commit) string {
293+ if c.Signature != "G" || c.Key == "" {
294+ return ""
295+ }
296+ if u, ok := s.store.UserByFingerprint(c.Key); ok {
297+ return u.Name
298+ }
299+ return ""
300+}
301+
302+func (s *Server) writeSigners() {
303+ keys, err := s.store.AllKeys()
304+ if err != nil {
305+ return
306+ }
307+ var b strings.Builder
308+ for _, k := range keys {
309+ b.WriteString("* namespaces=\"git\" " + k + "\n")
310+ }
311+ path := filepath.Join(s.root, "signers")
312+ if err := os.WriteFile(path+".tmp", []byte(b.String()), 0o644); err == nil {
313+ if os.Rename(path+".tmp", path) == nil {
314+ signersFile = path
315+ }
316+ }
317+}
318+
290319 func visibility(v string) int {
291320 switch v {
292321 case "private":
@@ -474,6 +503,7 @@ func (s *Server) addKey(w http.ResponseWriter, r *http.Request) {
474503 http.Redirect(w, r, "/settings/keys?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
475504 return
476505 }
506+ s.writeSigners()
477507 http.Redirect(w, r, "/settings/keys", http.StatusSeeOther)
478508 }
479509
@@ -484,6 +514,7 @@ func (s *Server) deleteKey(w http.ResponseWriter, r *http.Request) {
484514 }
485515 id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
486516 s.store.DeleteKey(u.ID, id)
517+ s.writeSigners()
487518 http.Redirect(w, r, "/settings/keys", http.StatusSeeOther)
488519 }
489520