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.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 23m ago 4b8c9e1d
history blame raw 4.3 KiB
1package 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
15import (
16 "errors"
17 "fmt"
18 "net/http"
19 "net/url"
20 "strings"
21
22 "rodent.software/vole/forge/secret"
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.
29const 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.
34func (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.
66func (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
94func (w *Web) redirectSecrets(rw http.ResponseWriter, r *http.Request, ctx *settingsContext, saved, deleted string) {
95 target := "/" + ctx.m.Name + "/settings?tab=secrets#secrets"
96 switch {
97 case saved != "":
98 target = "/" + ctx.m.Name + "/settings?tab=secrets&secret_saved=" + url.QueryEscape(saved) + "#secrets"
99 case deleted != "":
100 target = "/" + ctx.m.Name + "/settings?tab=secrets&secret_deleted=" + url.QueryEscape(deleted) + "#secrets"
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.
107func (w *Web) secretFailure(rw http.ResponseWriter, r *http.Request, ctx *settingsContext, msg string) {
108 v := w.settingsView(r, ctx)
109 v.focusTab("secrets")
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.
117func 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}
127