Commit a21d67902c

a21d67902c7598558db120b85ec975ebf175f9ac

parent: 1bbdc4b3b9

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-12 00:56 UTC

web: create, edit and delete snippets

Every form dispatches the snippet command the CLI runs.

Ref #195
e2e/snippetweb_test.go +67 −2
@@ -93,6 +93,71 @@ func TestSnippetsWeb(t *testing.T) {
9393 t.Fatalf("bob's page shows a snippets link with nothing to list: %d", status)
9494 }
9595
96 _ = url.Values{}
97 _ = bobKey
96 // The create form makes a snippet through snippet create.
97 status, body = browserPost(t, alice, inst.base()+"/alice/-/snippets/new", url.Values{
98 "name": {"notes.md"}, "description": {"from the browser"}, "visibility": {"public"}, "content": {"# notes\n"}})
99 if status != 200 || !strings.Contains(body, "from the browser") || !strings.Contains(body, "notes.md") {
100 t.Fatalf("create form: %d\n%s", status, body)
101 }
102 var listed struct {
103 Data []struct {
104 ID string `json:"id"`
105 Description string `json:"description"`
106 } `json:"data"`
107 }
108 json.Unmarshal([]byte(must(aliceKey, "", "snippet", "list", "--json")), &listed)
109 created := ""
110 for _, sn := range listed.Data {
111 if sn.Description == "from the browser" {
112 created = sn.ID
113 }
114 }
115 if created == "" {
116 t.Fatalf("created from the web, not listed: %+v", listed.Data)
117 }
118 if status, _ := browserGet(t, alice, inst.base()+"/bob/-/snippets/new"); status != 404 {
119 t.Fatalf("new form under another owner: %d", status)
120 }
121
122 // The file form replaces a file and adds one; remove drops it.
123 page := inst.base() + "/alice/-/snippets/" + created
124 if status, _ := browserPost(t, alice, page+"/file", url.Values{"name": {"notes.md"}, "content": {"# changed\n"}}); status != 200 {
125 t.Fatal("file replace failed")
126 }
127 if got := must(aliceKey, "", "snippet", "file", "get", created, "notes.md"); got != "# changed\n" {
128 t.Fatalf("after web replace: %q", got)
129 }
130 if status, _ := browserPost(t, alice, page+"/file", url.Values{"name": {"b.txt"}, "content": {"b\n"}}); status != 200 {
131 t.Fatal("file add failed")
132 }
133 if status, _ := browserPost(t, alice, page+"/file/remove", url.Values{"name": {"b.txt"}}); status != 200 {
134 t.Fatal("file remove failed")
135 }
136 if _, _, code := inst.ssh(t, aliceKey, "", "snippet", "file", "get", created, "b.txt"); code != 3 {
137 t.Fatalf("b.txt after web remove: exit %d", code)
138 }
139 // A refusal comes back on the page as a message, not a bare error.
140 _, body = browserPost(t, alice, page+"/file/remove", url.Values{"name": {"notes.md"}})
141 if !strings.Contains(body, `class="error"`) || !strings.Contains(body, "at least one file") {
142 t.Fatalf("last-file refusal on the page:\n%s", body)
143 }
144
145 // Edit changes visibility; delete removes.
146 if status, _ := browserPost(t, alice, page+"/edit", url.Values{"description": {"renamed"}, "visibility": {"private"}}); status != 200 {
147 t.Fatal("edit failed")
148 }
149 if status, _ := inst.get(t, "/alice/-/snippets/"+created); status != 404 {
150 t.Fatalf("private after web edit, anonymous: %d", status)
151 }
152 // bob cannot write alice's snippet from the browser either.
153 bob := inst.login(t, bobKey)
154 if status, _ := browserPost(t, bob, inst.base()+"/alice/-/snippets/"+public+"/edit", url.Values{"description": {"x"}, "visibility": {"public"}}); status != 403 {
155 t.Fatalf("bob editing alice's snippet: %d", status)
156 }
157 if status, _ := browserPost(t, alice, page+"/delete", nil); status != 200 {
158 t.Fatal("delete failed")
159 }
160 if _, _, code := inst.ssh(t, aliceKey, "", "snippet", "show", created); code != 3 {
161 t.Fatalf("after web delete: exit %d", code)
162 }
98163}
internal/httpd/routes.go +11
@@ -162,6 +162,17 @@ func (s *Server) Routes() []Route {
162162 Route{Method: "POST", Pattern: "/{owner}/{repo}/labels", Mutating: true,
163163 Handler: s.checkOrigin(s.requireUser(s.labelSubmit))},
164164 Route{Method: "GET", Pattern: "/bookmarks", Handler: s.requireUser(s.bookmarksPage)},
165 Route{Method: "GET", Pattern: "/{owner}/-/snippets/new", Handler: s.requireUser(s.snippetNewForm)},
166 Route{Method: "POST", Pattern: "/{owner}/-/snippets/new", Mutating: true,
167 Handler: s.checkOrigin(s.requireUser(s.snippetNewSubmit))},
168 Route{Method: "POST", Pattern: "/{owner}/-/snippets/{id}/edit", Mutating: true,
169 Handler: s.checkOrigin(s.requireUser(s.snippetEditSubmit))},
170 Route{Method: "POST", Pattern: "/{owner}/-/snippets/{id}/delete", Mutating: true,
171 Handler: s.checkOrigin(s.requireUser(s.snippetDeleteSubmit))},
172 Route{Method: "POST", Pattern: "/{owner}/-/snippets/{id}/file", Mutating: true,
173 Handler: s.checkOrigin(s.requireUser(s.snippetFileSubmit))},
174 Route{Method: "POST", Pattern: "/{owner}/-/snippets/{id}/file/remove", Mutating: true,
175 Handler: s.checkOrigin(s.requireUser(s.snippetFileRemoveSubmit))},
165176 Route{Method: "POST", Pattern: "/{owner}/{repo}/bookmark", Mutating: true,
166177 Handler: s.checkOrigin(s.requireUser(s.bookmarkToggle))},
167178 Route{Method: "POST", Pattern: "/{owner}/{repo}/fork", Mutating: true,
internal/httpd/snippets.go +84
@@ -4,8 +4,11 @@ import (
44 "bytes"
55 "html/template"
66 "net/http"
7 "strings"
78
9 "gitbay.org/gitbay/internal/control"
810 "gitbay.org/gitbay/internal/policy"
11 "gitbay.org/gitbay/internal/protocol"
912 "gitbay.org/gitbay/internal/store"
1013)
1114
@@ -113,3 +116,84 @@ func (s *Server) snippetRaw(w http.ResponseWriter, r *http.Request) {
113116 w.Header().Set("X-Content-Type-Options", "nosniff")
114117 w.Write(f.Content)
115118}
119
120// snippetNewForm is the owner's own page only: the URL names the owner
121// and a snippet cannot be created for someone else.
122func (s *Server) snippetNewForm(w http.ResponseWriter, r *http.Request, u store.User) {
123 if r.PathValue("owner") != u.Username {
124 s.notFound(w, r)
125 return
126 }
127 s.render(w, "snippetnew.html", struct {
128 basePage
129 Owner string
130 }{s.baseFor(u), u.Username})
131}
132
133func (s *Server) snippetNewSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
134 if r.PathValue("owner") != u.Username {
135 s.notFound(w, r)
136 return
137 }
138 argv := []string{"snippet", "create", strings.TrimSpace(r.FormValue("name")),
139 "--description", strings.TrimSpace(r.FormValue("description")),
140 "--visibility", r.FormValue("visibility")}
141 var out control.SnippetOut
142 code, msg := s.dispatchIntoStdin(u, argv, r.FormValue("content"), &out)
143 if code != protocol.ExitOK {
144 http.Error(w, msg, statusForExit(code))
145 return
146 }
147 http.Redirect(w, r, "/"+u.Username+"/-/snippets/"+out.ID, http.StatusSeeOther)
148}
149
150// snippetAction runs a write on the snippet in the URL and returns to
151// its page with the message, or to the list after a delete. A snippet
152// the viewer may not read is the 404 page, as on every read.
153func (s *Server) snippetAction(w http.ResponseWriter, r *http.Request, u store.User, argv []string, stdin string, dest string) {
154 sn, _, ok := s.snippetScope(w, r)
155 if !ok {
156 return
157 }
158 if dest == "" {
159 dest = "/" + sn.OwnerName + "/-/snippets/" + sn.PublicID
160 }
161 back := func(w http.ResponseWriter, r *http.Request, msg string) {
162 s.setFlash(w, msg)
163 http.Redirect(w, r, dest, http.StatusSeeOther)
164 }
165 var msg string
166 var code int
167 if stdin == "" {
168 _, msg, code = s.runControlCode(u, argv)
169 } else {
170 msg, code = s.runControlStdinCode(u, argv, stdin)
171 }
172 if code == protocol.ExitDenied {
173 http.Error(w, msg, http.StatusForbidden)
174 return
175 }
176 s.done(w, r, code, msg, back)
177}
178
179func (s *Server) snippetEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
180 s.snippetAction(w, r, u, []string{"snippet", "edit", r.PathValue("id"),
181 "--description", strings.TrimSpace(r.FormValue("description")),
182 "--visibility", r.FormValue("visibility")}, "", "")
183}
184
185func (s *Server) snippetDeleteSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
186 s.snippetAction(w, r, u, []string{"snippet", "delete", r.PathValue("id")}, "",
187 "/"+r.PathValue("owner")+"/-/snippets")
188}
189
190// An empty textarea reaches the command as empty stdin, which it refuses;
191// the message lands on the page like any other.
192func (s *Server) snippetFileSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
193 s.snippetAction(w, r, u, []string{"snippet", "file", "set", r.PathValue("id"), strings.TrimSpace(r.FormValue("name"))},
194 r.FormValue("content"), "")
195}
196
197func (s *Server) snippetFileRemoveSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
198 s.snippetAction(w, r, u, []string{"snippet", "file", "remove", r.PathValue("id"), strings.TrimSpace(r.FormValue("name"))}, "", "")
199}
internal/web/static/style.css +1
@@ -1499,6 +1499,7 @@ table.tree td.name.dir a { color: var(--accent); }
14991499p.clone { margin: 0 0 var(--sp-3); }
15001500p.filefacts { color: var(--muted); font-size: var(--fs-1); margin: 0 0 var(--sp-3); }
15011501.pathbar .actions { font-size: var(--fs-1); color: var(--muted); }
1502.snippetfile { margin-bottom: 1.5rem }
15021503
15031504/* merge request: a two-column split, so state has somewhere to live that
15041505 is not a run-on sentence under the title */
internal/web/templates/snippet.html +33
@@ -13,6 +13,39 @@
1313</div>
1414<p class="filefacts">{{.Lines}} lines · {{.Size}} bytes</p>
1515<div class="code">{{.HTML}}</div>
16{{if $.CanWrite}}<details class="editbox"><summary>edit {{.Name}}</summary>
17<form method="post" action="/{{$.Owner}}/-/snippets/{{$.Snippet.PublicID}}/file" class="commentform">
18<input type="hidden" name="name" value="{{.Name}}">
19<p><textarea name="content" aria-label="Content of {{.Name}}" rows="12">{{.Content}}</textarea></p>
20<p><button type="submit">Save</button></p>
21</form>
22<form method="post" action="/{{$.Owner}}/-/snippets/{{$.Snippet.PublicID}}/file/remove">
23<input type="hidden" name="name" value="{{.Name}}">
24<p><button type="submit">Remove {{.Name}}</button></p>
25</form>
26</details>{{end}}
1627</section>
1728{{end}}
29{{if .CanWrite}}
30<details class="editbox"><summary>add a file</summary>
31<form method="post" action="/{{.Owner}}/-/snippets/{{.Snippet.PublicID}}/file" class="commentform">
32<p><input type="text" name="name" aria-label="File name" placeholder="filename" required></p>
33<p><textarea name="content" aria-label="Content" rows="12" required></textarea></p>
34<p><button type="submit">Add file</button></p>
35</form></details>
36<details class="editbox"><summary>settings</summary>
37<form method="post" action="/{{.Owner}}/-/snippets/{{.Snippet.PublicID}}/edit">
38<p><input type="text" name="description" aria-label="Description" value="{{.Snippet.Description}}" placeholder="description"></p>
39<p><select name="visibility" aria-label="Visibility">
40<option value="public"{{if eq .Snippet.Visibility "public"}} selected{{end}}>public</option>
41<option value="unlisted"{{if eq .Snippet.Visibility "unlisted"}} selected{{end}}>unlisted</option>
42<option value="private"{{if eq .Snippet.Visibility "private"}} selected{{end}}>private</option>
43</select></p>
44<p><button type="submit">Save</button></p>
45</form>
46<form method="post" action="/{{.Owner}}/-/snippets/{{.Snippet.PublicID}}/delete">
47<p><button type="submit">Delete snippet</button></p>
48</form>
49</details>
50{{end}}
1851{{end}}
internal/web/templates/snippetnew.html added +11
@@ -0,0 +1,11 @@
1{{define "title"}}new snippet · {{.Owner}}{{end}}
2{{define "content"}}
3<h1>New snippet</h1>
4<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>
9<p><button type="submit">Create snippet</button></p>
10</form>
11{{end}}