Commit 5d1843ee18

5d1843ee1898df5d06374682cec982499a361616

parent: ebf1e30fe0

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-12 01:28 UTC

httpd: keep the paste on a refused snippet create

snippetNewSubmit re-renders snippetnew.html with the submitted name,
description, visibility and content plus the failure message, instead
of a bare http.Error that threw the paste away. Also covers the
existing 404/200 visibility split for a logged-in stranger, not just
anonymous, on a private and an unlisted snippet page.

Ref #195
e2e/snippetweb_test.go +16
@@ -119,6 +119,14 @@ func TestSnippetsWeb(t *testing.T) {
119119 t.Fatalf("new form under another owner: %d", status)
120120 }
121121
122 // A refused create re-renders the form with the paste kept, not a
123 // bare error page.
124 _, body = browserPost(t, alice, inst.base()+"/alice/-/snippets/new", url.Values{
125 "name": {"../x"}, "content": {"kept content\n"}})
126 if !strings.Contains(body, `class="error"`) || !strings.Contains(body, "kept content") {
127 t.Fatalf("refused create form:\n%s", body)
128 }
129
122130 // The file form replaces a file and adds one; remove drops it.
123131 page := inst.base() + "/alice/-/snippets/" + created
124132 if status, _ := browserPost(t, alice, page+"/file", url.Values{"name": {"notes.md"}, "content": {"# changed\n"}}); status != 200 {
@@ -151,6 +159,14 @@ func TestSnippetsWeb(t *testing.T) {
151159 }
152160 // bob cannot write alice's snippet from the browser either.
153161 bob := inst.login(t, bobKey)
162 // A logged-in stranger sees the same visibility rule as anonymous:
163 // 404 for a private snippet, 200 for an unlisted one.
164 if status, _ := browserGet(t, bob, inst.base()+"/alice/-/snippets/"+private); status != 404 {
165 t.Fatalf("stranger on a private page: %d", status)
166 }
167 if status, _ := browserGet(t, bob, inst.base()+"/alice/-/snippets/"+unlisted); status != 200 {
168 t.Fatalf("stranger on an unlisted page: %d", status)
169 }
154170 if status, _ := browserPost(t, bob, inst.base()+"/alice/-/snippets/"+public+"/edit", url.Values{"description": {"x"}, "visibility": {"public"}}); status != 403 {
155171 t.Fatalf("bob editing alice's snippet: %d", status)
156172 }
internal/httpd/snippets.go +23 −9
@@ -134,6 +134,16 @@ func (s *Server) snippetRaw(w http.ResponseWriter, r *http.Request) {
134134 w.Write(f.Content)
135135}
136136
137type snippetNewPage struct {
138 basePage
139 Owner string
140 Name string
141 Description string
142 Visibility string
143 Content string
144 Error string
145}
146
137147// snippetNewForm is the owner's own page only: the URL names the owner
138148// and a snippet cannot be created for someone else.
139149func (s *Server) snippetNewForm(w http.ResponseWriter, r *http.Request, u store.User) {
@@ -141,24 +151,28 @@ func (s *Server) snippetNewForm(w http.ResponseWriter, r *http.Request, u store.
141151 s.notFound(w, r)
142152 return
143153 }
144 s.render(w, "snippetnew.html", struct {
145 basePage
146 Owner string
147 }{s.baseFor(u), u.Username})
154 s.render(w, "snippetnew.html", snippetNewPage{basePage: s.baseFor(u), Owner: u.Username})
148155}
149156
157// snippetNewSubmit re-renders the form with the submitted values on a
158// refusal, so a typo in the name does not throw away a pasted body.
150159func (s *Server) snippetNewSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
151160 if r.PathValue("owner") != u.Username {
152161 s.notFound(w, r)
153162 return
154163 }
155 argv := []string{"snippet", "create", strings.TrimSpace(r.FormValue("name")),
156 "--description", strings.TrimSpace(r.FormValue("description")),
157 "--visibility", r.FormValue("visibility")}
164 name := strings.TrimSpace(r.FormValue("name"))
165 description := strings.TrimSpace(r.FormValue("description"))
166 visibility := r.FormValue("visibility")
167 content := r.FormValue("content")
168 argv := []string{"snippet", "create", name, "--description", description, "--visibility", visibility}
158169 var out control.SnippetOut
159 code, msg := s.dispatchIntoStdin(u, argv, r.FormValue("content"), &out)
170 code, msg := s.dispatchIntoStdin(u, argv, content, &out)
160171 if code != protocol.ExitOK {
161 http.Error(w, msg, statusForExit(code))
172 s.render(w, "snippetnew.html", snippetNewPage{
173 basePage: s.baseFor(u), Owner: u.Username,
174 Name: name, Description: description, Visibility: visibility, Content: content, Error: msg,
175 })
162176 return
163177 }
164178 http.Redirect(w, r, "/"+u.Username+"/-/snippets/"+out.ID, http.StatusSeeOther)
internal/web/templates/snippetnew.html +9 −4
@@ -1,11 +1,16 @@
11{{define "title"}}new snippet · {{.Owner}}{{end}}
22{{define "content"}}
33<h1>New snippet</h1>
4{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
45<form method="post" action="/{{.Owner}}/-/snippets/new" class="commentform">
5<p><input type="text" name="name" aria-label="File name" placeholder="filename" required></p>
6<p><input type="text" name="description" aria-label="Description" placeholder="description"></p>
7<p><select name="visibility" aria-label="Visibility"><option value="unlisted">unlisted</option><option value="public">public</option><option value="private">private</option></select></p>
8<p><textarea name="content" aria-label="Content" rows="16" required></textarea></p>
6<p><input type="text" name="name" aria-label="File name" placeholder="filename" value="{{.Name}}" required></p>
7<p><input type="text" name="description" aria-label="Description" placeholder="description" value="{{.Description}}"></p>
8<p><select name="visibility" aria-label="Visibility">
9<option value="unlisted"{{if or (eq .Visibility "unlisted") (eq .Visibility "")}} selected{{end}}>unlisted</option>
10<option value="public"{{if eq .Visibility "public"}} selected{{end}}>public</option>
11<option value="private"{{if eq .Visibility "private"}} selected{{end}}>private</option>
12</select></p>
13<p><textarea name="content" aria-label="Content" rows="16" required>{{.Content}}</textarea></p>
914<p><button type="submit">Create snippet</button></p>
1015</form>
1116{{end}}