Commit a21d67902c
Verified · cmc
e2e/snippetweb_test.go +67 −2
| @@ -93,6 +93,71 @@ func TestSnippetsWeb(t *testing.T) { | ||
| 93 | 93 | t.Fatalf("bob's page shows a snippets link with nothing to list: %d", status) |
| 94 | 94 | } |
| 95 | 95 | |
| 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 | } | |
| 98 | 163 | } |
internal/httpd/routes.go +11
| @@ -162,6 +162,17 @@ func (s *Server) Routes() []Route { | ||
| 162 | 162 | Route{Method: "POST", Pattern: "/{owner}/{repo}/labels", Mutating: true, |
| 163 | 163 | Handler: s.checkOrigin(s.requireUser(s.labelSubmit))}, |
| 164 | 164 | 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))}, | |
| 165 | 176 | Route{Method: "POST", Pattern: "/{owner}/{repo}/bookmark", Mutating: true, |
| 166 | 177 | Handler: s.checkOrigin(s.requireUser(s.bookmarkToggle))}, |
| 167 | 178 | Route{Method: "POST", Pattern: "/{owner}/{repo}/fork", Mutating: true, |
internal/httpd/snippets.go +84
| @@ -4,8 +4,11 @@ import ( | ||
| 4 | 4 | "bytes" |
| 5 | 5 | "html/template" |
| 6 | 6 | "net/http" |
| 7 | "strings" | |
| 7 | 8 | |
| 9 | "gitbay.org/gitbay/internal/control" | |
| 8 | 10 | "gitbay.org/gitbay/internal/policy" |
| 11 | "gitbay.org/gitbay/internal/protocol" | |
| 9 | 12 | "gitbay.org/gitbay/internal/store" |
| 10 | 13 | ) |
| 11 | 14 | |
| @@ -113,3 +116,84 @@ func (s *Server) snippetRaw(w http.ResponseWriter, r *http.Request) { | ||
| 113 | 116 | w.Header().Set("X-Content-Type-Options", "nosniff") |
| 114 | 117 | w.Write(f.Content) |
| 115 | 118 | } |
| 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. | |
| 122 | func (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 | ||
| 133 | func (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. | |
| 153 | func (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 | ||
| 179 | func (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 | ||
| 185 | func (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. | |
| 192 | func (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 | ||
| 197 | func (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); } | ||
| 1499 | 1499 | p.clone { margin: 0 0 var(--sp-3); } |
| 1500 | 1500 | p.filefacts { color: var(--muted); font-size: var(--fs-1); margin: 0 0 var(--sp-3); } |
| 1501 | 1501 | .pathbar .actions { font-size: var(--fs-1); color: var(--muted); } |
| 1502 | .snippetfile { margin-bottom: 1.5rem } | |
| 1502 | 1503 | |
| 1503 | 1504 | /* merge request: a two-column split, so state has somewhere to live that |
| 1504 | 1505 | is not a run-on sentence under the title */ |
internal/web/templates/snippet.html +33
| @@ -13,6 +13,39 @@ | ||
| 13 | 13 | </div> |
| 14 | 14 | <p class="filefacts">{{.Lines}} lines · {{.Size}} bytes</p> |
| 15 | 15 | <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}} | |
| 16 | 27 | </section> |
| 17 | 28 | {{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}} | |
| 18 | 51 | {{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}} | |