Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Blame · server/web/secrets.go

Line attribution on main · 126 line(s)

0c40a1d3
Keenan Nemetz
7d ago
1 package web
2
3 // Repository secrets: the settings page half.
4 //
5 // The page lists what a repository has — names, versions, who last changed one,
6 // and when a value was last read — and it sets, replaces and deletes. It never
7 // renders a value, because there is no way to show one that does not end up in a
8 // screenshot, a browser cache, or a shoulder. A person who needs the value itself
9 // runs `vole secret get`.
10 //
11 // The capability is the maintainer one, the same as the CLI verb: the accounts
12 // that can approve work and allow CI are the accounts that own the credentials the
13 // repository's jobs use.
14
15 import (
16 "errors"
17 "fmt"
18 "net/http"
19 "net/url"
20 "strings"
21
d37d301f
Keenan Nemetz
15h ago
22 "rodent.software/vole/forge/secret"
0c40a1d3
Keenan Nemetz
7d ago
23 )
24
25 // maxSecretForm bounds the set form. The store's limit is one environment
26 // variable, but a form body is URL-encoded: a newline is six bytes and a space is
27 // three, so the body can be several times the value. This is the ceiling, not the
28 // limit the reader is told about.
29 const maxSecretForm = 2 << 20
30
31 // SaveSecret stores one value, replacing whatever the name held. The form is
32 // write-only: the response redirects to the list, which shows the name and the new
33 // version and never the value.
34 func (w *Web) SaveSecret(rw http.ResponseWriter, r *http.Request) {
35 ctx, ok := w.settingsContext(rw, r)
36 if !ok {
37 return
38 }
39 if !w.readForm(rw, r, maxSecretForm) {
40 return
41 }
42 name := strings.TrimSpace(r.PostFormValue("secret_name"))
43 // A browser submits a textarea with CRLF line ends. A value with a stray CR in
44 // it is a different value from the one the person pasted, so the form's line
45 // ends become the ones a file has.
46 value := strings.ReplaceAll(r.PostFormValue("secret_value"), "\r\n", "\n")
47 value = strings.ReplaceAll(value, "\r", "\n")
48
49 if err := secret.ValidateName(name); err != nil {
50 w.secretFailure(rw, r, ctx, sentence(err.Error()))
51 return
52 }
53 if w.Secrets == nil {
54 w.secretFailure(rw, r, ctx, "This instance has no secret store.")
55 return
56 }
57 version, err := w.Secrets.Set(ctx.m.ID, name, value, ctx.pr.account.Name)
58 if err != nil {
59 w.secretFailure(rw, r, ctx, sentence(err.Error()))
60 return
61 }
62 w.redirectSecrets(rw, r, ctx, fmt.Sprintf("%s v%d", name, version), "")
63 }
64
65 // DeleteSecret forgets one value.
66 func (w *Web) DeleteSecret(rw http.ResponseWriter, r *http.Request) {
67 ctx, ok := w.settingsContext(rw, r)
68 if !ok {
69 return
70 }
71 if !w.readForm(rw, r, maxSmallForm) {
72 return
73 }
74 name := strings.TrimSpace(r.PostFormValue("secret_name"))
75 if err := secret.ValidateName(name); err != nil {
76 w.secretFailure(rw, r, ctx, sentence(err.Error()))
77 return
78 }
79 if w.Secrets == nil {
80 w.secretFailure(rw, r, ctx, "This instance has no secret store.")
81 return
82 }
83 if err := w.Secrets.Delete(ctx.m.ID, name, ctx.pr.account.Name); err != nil {
84 if errors.Is(err, secret.ErrNotFound) {
85 w.secretFailure(rw, r, ctx, "There is no secret named "+name+" in this repository.")
86 return
87 }
88 w.secretFailure(rw, r, ctx, sentence(err.Error()))
89 return
90 }
91 w.redirectSecrets(rw, r, ctx, "", name)
92 }
93
94 func (w *Web) redirectSecrets(rw http.ResponseWriter, r *http.Request, ctx *settingsContext, saved, deleted string) {
ed03b3d1
Keenan Nemetz
2h ago
95 target := "/" + ctx.m.Name + "/settings?tab=secrets#secrets"
0c40a1d3
Keenan Nemetz
7d ago
96 switch {
97 case saved != "":
ed03b3d1
Keenan Nemetz
2h ago
98 target = "/" + ctx.m.Name + "/settings?tab=secrets&secret_saved=" + url.QueryEscape(saved) + "#secrets"
0c40a1d3
Keenan Nemetz
7d ago
99 case deleted != "":
ed03b3d1
Keenan Nemetz
2h ago
100 target = "/" + ctx.m.Name + "/settings?tab=secrets&secret_deleted=" + url.QueryEscape(deleted) + "#secrets"
0c40a1d3
Keenan Nemetz
7d ago
101 }
102 http.Redirect(rw, r, target, http.StatusSeeOther)
103 }
104
105 // secretFailure re-renders the page with the message beside the form. The value is
106 // not echoed back: a refused value is not put into the page that just refused it.
107 func (w *Web) secretFailure(rw http.ResponseWriter, r *http.Request, ctx *settingsContext, msg string) {
108 v := w.settingsView(r, ctx)
e189a23e
Keenan Nemetz
22h ago
109 v.focusTab("secrets")
0c40a1d3
Keenan Nemetz
7d ago
110 v.SecretError = msg
111 w.render(rw, "settings", v, http.StatusBadRequest)
112 }
113
114 // sentence makes a store error read as a sentence on a page. The store's errors
115 // are written for a terminal, so a leading lower-case letter is raised; nothing
116 // else is changed, because the message is the explanation.
117 func sentence(msg string) string {
118 msg = strings.TrimSpace(msg)
119 if msg == "" {
120 return ""
121 }
122 if first := msg[0]; first >= 'a' && first <= 'z' {
123 msg = string(first-'a'+'A') + msg[1:]
124 }
125 return msg
126 }