Commit 1bbdc4b3b9
Verified · cmc
e2e/snippetweb_test.go added +98
| @@ -0,0 +1,98 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "encoding/json" | |
| 5 | "net/http" | |
| 6 | "net/url" | |
| 7 | "strings" | |
| 8 | "testing" | |
| 9 | ) | |
| 10 | ||
| 11 | func snippetIDFrom(t *testing.T, out string) string { | |
| 12 | t.Helper() | |
| 13 | var env struct { | |
| 14 | Data struct { | |
| 15 | ID string `json:"id"` | |
| 16 | } `json:"data"` | |
| 17 | } | |
| 18 | if err := json.Unmarshal([]byte(out), &env); err != nil || env.Data.ID == "" { | |
| 19 | t.Fatalf("snippet create: %s", out) | |
| 20 | } | |
| 21 | return env.Data.ID | |
| 22 | } | |
| 23 | ||
| 24 | // Snippet pages: the owner's list, one snippet with highlighted files, the | |
| 25 | // raw route, the owner-page link, and 404 for what the viewer may not see. | |
| 26 | func TestSnippetsWeb(t *testing.T) { | |
| 27 | inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n") | |
| 28 | aliceKey := inst.newKey(t, "alice") | |
| 29 | bobKey := inst.newKey(t, "bob") | |
| 30 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") | |
| 31 | inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub", "--email", "bob@example.test", "--verified") | |
| 32 | must := func(key, stdin string, args ...string) string { | |
| 33 | t.Helper() | |
| 34 | out, errOut, code := inst.ssh(t, key, stdin, args...) | |
| 35 | if code != 0 { | |
| 36 | t.Fatalf("%v: exit %d %s", args, code, errOut) | |
| 37 | } | |
| 38 | return out | |
| 39 | } | |
| 40 | public := snippetIDFrom(t, must(aliceKey, "package main\n", "snippet", "create", "main.go", "--visibility", "public", "--description", "'hello world'", "--json")) | |
| 41 | unlisted := snippetIDFrom(t, must(aliceKey, "quiet\n", "snippet", "create", "q.txt", "--json")) | |
| 42 | private := snippetIDFrom(t, must(aliceKey, "secret\n", "snippet", "create", "s.txt", "--visibility", "private", "--json")) | |
| 43 | ||
| 44 | // Anonymous: the public list, the unlisted page by URL, 404 for private. | |
| 45 | status, body := inst.get(t, "/alice/-/snippets") | |
| 46 | if status != 200 || !strings.Contains(body, public) || strings.Contains(body, unlisted) || strings.Contains(body, private) { | |
| 47 | t.Fatalf("anonymous list: %d\n%s", status, body) | |
| 48 | } | |
| 49 | status, body = inst.get(t, "/alice/-/snippets/"+public) | |
| 50 | if status != 200 || !strings.Contains(body, "hello world") || !strings.Contains(body, `class="chroma"`) || !strings.Contains(body, "/raw/main.go") { | |
| 51 | t.Fatalf("public page: %d\n%s", status, body) | |
| 52 | } | |
| 53 | if status, _ := inst.get(t, "/alice/-/snippets/"+unlisted); status != 200 { | |
| 54 | t.Fatalf("unlisted page: %d", status) | |
| 55 | } | |
| 56 | if status, _ := inst.get(t, "/alice/-/snippets/"+private); status != 404 { | |
| 57 | t.Fatalf("private page for anonymous: %d", status) | |
| 58 | } | |
| 59 | if status, _ := inst.get(t, "/bob/-/snippets/"+public); status != 404 { | |
| 60 | t.Fatalf("id under the wrong owner: %d", status) | |
| 61 | } | |
| 62 | if status, _ := inst.get(t, "/nobody/-/snippets"); status != 404 { | |
| 63 | t.Fatalf("list for a missing owner: %d", status) | |
| 64 | } | |
| 65 | ||
| 66 | // Raw is text/plain with nosniff, whatever the extension. | |
| 67 | resp, err := http.Get(inst.base() + "/alice/-/snippets/" + public + "/raw/main.go") | |
| 68 | if err != nil { | |
| 69 | t.Fatal(err) | |
| 70 | } | |
| 71 | resp.Body.Close() | |
| 72 | if resp.StatusCode != 200 || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/plain") || resp.Header.Get("X-Content-Type-Options") != "nosniff" { | |
| 73 | t.Fatalf("raw headers: %d %v", resp.StatusCode, resp.Header) | |
| 74 | } | |
| 75 | if status, _ := inst.get(t, "/alice/-/snippets/" + public + "/raw/other.go"); status != 404 { | |
| 76 | t.Fatalf("raw for a missing file: %d", status) | |
| 77 | } | |
| 78 | ||
| 79 | // The owner sees everything with visibility marks; the owner page links. | |
| 80 | alice := inst.login(t, aliceKey) | |
| 81 | status, body = browserGet(t, alice, inst.base()+"/alice/-/snippets") | |
| 82 | if status != 200 || !strings.Contains(body, private) || !strings.Contains(body, ">private<") { | |
| 83 | t.Fatalf("owner list: %d\n%s", status, body) | |
| 84 | } | |
| 85 | if status, body := browserGet(t, alice, inst.base()+"/alice/-/snippets/"+private); status != 200 || !strings.Contains(body, "secret") { | |
| 86 | t.Fatalf("owner's private page: %d", status) | |
| 87 | } | |
| 88 | if status, body := inst.get(t, "/alice"); status != 200 || !strings.Contains(body, `href="/alice/-/snippets"`) { | |
| 89 | t.Fatalf("owner page lacks the snippets link: %d", status) | |
| 90 | } | |
| 91 | // bob has no public snippets and is not the viewer: no link. | |
| 92 | if status, body := inst.get(t, "/bob"); status != 200 || strings.Contains(body, `href="/bob/-/snippets"`) { | |
| 93 | t.Fatalf("bob's page shows a snippets link with nothing to list: %d", status) | |
| 94 | } | |
| 95 | ||
| 96 | _ = url.Values{} | |
| 97 | _ = bobKey | |
| 98 | } | |
internal/control/profile.go +14 −4
| @@ -176,10 +176,13 @@ type ProfileOut struct { | ||
| 176 | 176 | // they own that you can see, and how active they have been. The web |
| 177 | 177 | // read these straight out of the store, which kept them off every |
| 178 | 178 | // other surface. |
| 179 | Orgs []ProfileMember `json:"orgs,omitempty"` // for a user | |
| 180 | Members []ProfileMember `json:"members,omitempty"` // for an org | |
| 181 | Repos []ProfileRepo `json:"repos"` | |
| 182 | Activity []ActivityDay `json:"activity,omitempty"` | |
| 179 | Orgs []ProfileMember `json:"orgs,omitempty"` // for a user | |
| 180 | Members []ProfileMember `json:"members,omitempty"` // for an org | |
| 181 | Repos []ProfileRepo `json:"repos"` | |
| 182 | // Snippets counts the owner's snippets the caller may list: public | |
| 183 | // ones, or all of them for the owner and admins. Orgs own none. | |
| 184 | Snippets int `json:"snippets"` | |
| 185 | Activity []ActivityDay `json:"activity,omitempty"` | |
| 183 | 186 | // ActivityTotal counts the same window the days cover. |
| 184 | 187 | ActivityTotal int `json:"activity_total"` |
| 185 | 188 | } |
| @@ -323,6 +326,13 @@ func runProfileShow(c *Ctx, args []string) int { | ||
| 323 | 326 | }) |
| 324 | 327 | } |
| 325 | 328 | |
| 329 | if kind == "user" { | |
| 330 | all := id == c.User.ID || c.User.IsAdmin | |
| 331 | if d.Snippets, err = c.Store.CountSnippets(id, all); err != nil { | |
| 332 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 333 | } | |
| 334 | } | |
| 335 | ||
| 326 | 336 | var counts map[string]int |
| 327 | 337 | if kind == "user" { |
| 328 | 338 | counts, err = c.Store.ActivityByDay(id, ActivityWindow()) |
internal/httpd/routes.go +3
| @@ -73,6 +73,9 @@ func (s *Server) Routes() []Route { | ||
| 73 | 73 | Route{Method: "GET", Pattern: "/{owner}/activity.atom", Handler: s.ownerAtom}, |
| 74 | 74 | Route{Method: "GET", Pattern: "/{owner}/-/labels", Handler: s.orgLabels}, |
| 75 | 75 | Route{Method: "GET", Pattern: "/{owner}/-/milestones", Handler: s.orgMilestones}, |
| 76 | Route{Method: "GET", Pattern: "/{owner}/-/snippets", Handler: s.snippetsPage}, | |
| 77 | Route{Method: "GET", Pattern: "/{owner}/-/snippets/{id}", Handler: s.snippetPage}, | |
| 78 | Route{Method: "GET", Pattern: "/{owner}/-/snippets/{id}/raw/{name}", Handler: s.snippetRaw}, | |
| 76 | 79 | Route{Method: "GET", Pattern: "/{owner}/{repo}/builds", Handler: s.builds}, |
| 77 | 80 | Route{Method: "GET", Pattern: "/{owner}/{repo}/badge/build.svg", Handler: s.buildBadge}, |
| 78 | 81 | Route{Method: "GET", Pattern: "/{owner}/{repo}/badge/build.png", Handler: s.buildBadgePNG}, |
internal/httpd/snippets.go added +115
| @@ -0,0 +1,115 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 4 | "bytes" | |
| 5 | "html/template" | |
| 6 | "net/http" | |
| 7 | ||
| 8 | "gitbay.org/gitbay/internal/policy" | |
| 9 | "gitbay.org/gitbay/internal/store" | |
| 10 | ) | |
| 11 | ||
| 12 | // snippetScope resolves the owner and id in the URL for the viewer. A | |
| 13 | // missing owner, an id under another owner, and a private snippet the | |
| 14 | // viewer may not read are all the same 404. | |
| 15 | func (s *Server) snippetScope(w http.ResponseWriter, r *http.Request) (store.Snippet, store.User, bool) { | |
| 16 | viewer := s.viewer(r) | |
| 17 | sn, err := s.st.SnippetByPublicID(r.PathValue("id")) | |
| 18 | if err != nil || sn.OwnerName != r.PathValue("owner") || !policy.CanReadSnippet(viewer, sn) { | |
| 19 | s.notFound(w, r) | |
| 20 | return sn, viewer, false | |
| 21 | } | |
| 22 | return sn, viewer, true | |
| 23 | } | |
| 24 | ||
| 25 | type snippetRow struct { | |
| 26 | store.Snippet | |
| 27 | Names string | |
| 28 | } | |
| 29 | ||
| 30 | func (s *Server) snippetsPage(w http.ResponseWriter, r *http.Request) { | |
| 31 | viewer := s.viewer(r) | |
| 32 | owner, err := s.st.UserByUsername(r.PathValue("owner")) | |
| 33 | if err != nil { | |
| 34 | s.notFound(w, r) | |
| 35 | return | |
| 36 | } | |
| 37 | self := viewer.ID != 0 && viewer.ID == owner.ID | |
| 38 | all := self || viewer.IsAdmin | |
| 39 | list, err := s.st.ListSnippets(owner.ID, all, 0, 0) | |
| 40 | if err != nil { | |
| 41 | http.Error(w, "internal error", http.StatusInternalServerError) | |
| 42 | return | |
| 43 | } | |
| 44 | rows := make([]snippetRow, 0, len(list)) | |
| 45 | for _, sn := range list { | |
| 46 | var names bytes.Buffer | |
| 47 | for i, f := range sn.Files { | |
| 48 | if i > 0 { | |
| 49 | names.WriteString(", ") | |
| 50 | } | |
| 51 | names.WriteString(f.Name) | |
| 52 | } | |
| 53 | rows = append(rows, snippetRow{sn, names.String()}) | |
| 54 | } | |
| 55 | s.render(w, "snippets.html", struct { | |
| 56 | basePage | |
| 57 | Owner string | |
| 58 | Self bool | |
| 59 | All bool | |
| 60 | Snippets []snippetRow | |
| 61 | Notice string | |
| 62 | }{s.baseFor(viewer), owner.Username, self, all, rows, s.takeFlash(w, r)}) | |
| 63 | } | |
| 64 | ||
| 65 | type snippetFileView struct { | |
| 66 | Name string | |
| 67 | Size int64 | |
| 68 | Lines int | |
| 69 | Content string | |
| 70 | HTML template.HTML | |
| 71 | } | |
| 72 | ||
| 73 | func (s *Server) snippetPage(w http.ResponseWriter, r *http.Request) { | |
| 74 | sn, viewer, ok := s.snippetScope(w, r) | |
| 75 | if !ok { | |
| 76 | return | |
| 77 | } | |
| 78 | files, err := s.st.SnippetFiles(sn.ID) | |
| 79 | if err != nil { | |
| 80 | http.Error(w, "internal error", http.StatusInternalServerError) | |
| 81 | return | |
| 82 | } | |
| 83 | views := make([]snippetFileView, 0, len(files)) | |
| 84 | for _, f := range files { | |
| 85 | lines := bytes.Count(f.Content, []byte("\n")) | |
| 86 | if len(f.Content) > 0 && f.Content[len(f.Content)-1] != '\n' { | |
| 87 | lines++ | |
| 88 | } | |
| 89 | views = append(views, snippetFileView{f.Name, f.Size, lines, string(f.Content), highlight(f.Name, f.Content)}) | |
| 90 | } | |
| 91 | s.render(w, "snippet.html", struct { | |
| 92 | basePage | |
| 93 | Owner string | |
| 94 | Snippet store.Snippet | |
| 95 | Files []snippetFileView | |
| 96 | CanWrite bool | |
| 97 | Notice string | |
| 98 | }{s.baseFor(viewer), sn.OwnerName, sn, views, policy.CanWriteSnippet(viewer, sn), s.takeFlash(w, r)}) | |
| 99 | } | |
| 100 | ||
| 101 | // snippetRaw serves one file as text, inert on the forge's origin. | |
| 102 | func (s *Server) snippetRaw(w http.ResponseWriter, r *http.Request) { | |
| 103 | sn, _, ok := s.snippetScope(w, r) | |
| 104 | if !ok { | |
| 105 | return | |
| 106 | } | |
| 107 | f, err := s.st.SnippetFile(sn.ID, r.PathValue("name")) | |
| 108 | if err != nil { | |
| 109 | s.notFound(w, r) | |
| 110 | return | |
| 111 | } | |
| 112 | w.Header().Set("Content-Type", "text/plain; charset=utf-8") | |
| 113 | w.Header().Set("X-Content-Type-Options", "nosniff") | |
| 114 | w.Write(f.Content) | |
| 115 | } | |
internal/httpd/web.go +2
| @@ -445,12 +445,14 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) { | ||
| 445 | 445 | Teams []teamView |
| 446 | 446 | CanAdmin bool |
| 447 | 447 | Self bool |
| 448 | Snippets int | |
| 448 | 449 | Notice string |
| 449 | 450 | Feed string |
| 450 | 451 | }{s.baseFor(viewer), name, d.Kind, profile, aboutHTML(profile), |
| 451 | 452 | d.Repos, d.Members, d.Orgs, |
| 452 | 453 | weeks, activityTotal, teams, canAdmin, |
| 453 | 454 | d.Kind == "user" && viewer.ID != 0 && strings.EqualFold(viewer.Username, name), |
| 455 | d.Snippets, | |
| 454 | 456 | s.takeFlash(w, r), "/" + name + "/activity.atom"}) |
| 455 | 457 | } |
| 456 | 458 | |
internal/web/templates/owner.html +1
| @@ -23,6 +23,7 @@ | ||
| 23 | 23 | {{range .Repos}}{{template "reporow" .}} |
| 24 | 24 | {{else}}<li class="empty">no visible repositories</li>{{end}} |
| 25 | 25 | </ul> |
| 26 | {{if or .Snippets .Self}}<p class="meta"><a href="/{{.Owner}}/-/snippets">snippets{{if .Snippets}} <span class="count">{{.Snippets}}</span>{{end}}</a></p>{{end}} | |
| 26 | 27 | |
| 27 | 28 | {{if .Self}} |
| 28 | 29 | <h2>organizations</h2> |
internal/web/templates/snippet.html added +18
| @@ -0,0 +1,18 @@ | ||
| 1 | {{define "title"}}{{if .Snippet.Description}}{{.Snippet.Description}}{{else}}{{.Snippet.PublicID}}{{end}} · {{.Owner}}{{end}} | |
| 2 | {{define "content"}} | |
| 3 | <h1><a href="/{{.Owner}}">{{.Owner}}</a> / <a href="/{{.Owner}}/-/snippets">snippets</a> / {{.Snippet.PublicID}}</h1> | |
| 4 | {{if .Snippet.Description}}<p class="desc lede">{{.Snippet.Description}}</p>{{end}} | |
| 5 | <p class="meta"><span class="chip chip-neutral">{{.Snippet.Visibility}}</span> · updated {{.Snippet.UpdatedAt}} · <code>gitbay snippet show {{.Snippet.PublicID}}</code></p> | |
| 6 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} | |
| 7 | {{range .Files}} | |
| 8 | <section class="snippetfile" id="file-{{.Name}}"> | |
| 9 | <div class="pathbar"> | |
| 10 | <span class="crumbs"><strong>{{.Name}}</strong></span> | |
| 11 | <span class="spacer"></span> | |
| 12 | <span class="actions"><a href="/{{$.Owner}}/-/snippets/{{$.Snippet.PublicID}}/raw/{{.Name}}">raw</a></span> | |
| 13 | </div> | |
| 14 | <p class="filefacts">{{.Lines}} lines · {{.Size}} bytes</p> | |
| 15 | <div class="code">{{.HTML}}</div> | |
| 16 | </section> | |
| 17 | {{end}} | |
| 18 | {{end}} | |
internal/web/templates/snippets.html added +16
| @@ -0,0 +1,16 @@ | ||
| 1 | {{define "title"}}snippets · {{.Owner}}{{end}} | |
| 2 | {{define "content"}} | |
| 3 | <h1><a href="/{{.Owner}}">{{.Owner}}</a> snippets</h1> | |
| 4 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} | |
| 5 | {{if .Self}}<p class="meta"><a href="/{{.Owner}}/-/snippets/new">new snippet</a> · or <code>gitbay snippet create <file> < file</code></p>{{end}} | |
| 6 | {{if .Snippets}}<div class="tablewrap"><table class="keys"> | |
| 7 | <tr class="cols"><th scope="col">snippet</th><th scope="col">files</th>{{if .All}}<th scope="col">visibility</th>{{end}}<th scope="col">updated</th></tr> | |
| 8 | {{range .Snippets}}<tr> | |
| 9 | <td><a href="/{{$.Owner}}/-/snippets/{{.PublicID}}">{{if .Description}}{{.Description}}{{else}}{{.PublicID}}{{end}}</a></td> | |
| 10 | <td><span class="mono">{{.Names}}</span></td> | |
| 11 | {{if $.All}}<td><span class="chip chip-neutral">{{.Visibility}}</span></td>{{end}} | |
| 12 | <td>{{.UpdatedAt}}</td> | |
| 13 | </tr> | |
| 14 | {{end}}</table></div> | |
| 15 | {{else}}<p class="none">No snippets yet.</p>{{end}} | |
| 16 | {{end}} | |