Commit 1bbdc4b3b9

1bbdc4b3b9a1498a966f8c294c602dd05d3e0a85

parent: 51479b2659

Verified · cmc

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

web: snippet pages

The owner's list, one snippet with highlighted files, and a raw route
under /{owner}/-/snippets. The owner page links when there is
something to list.

Ref #195
e2e/snippetweb_test.go added +98
@@ -0,0 +1,98 @@
1package e2e
2
3import (
4 "encoding/json"
5 "net/http"
6 "net/url"
7 "strings"
8 "testing"
9)
10
11func 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.
26func 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 {
176176 // they own that you can see, and how active they have been. The web
177177 // read these straight out of the store, which kept them off every
178178 // 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"`
183186 // ActivityTotal counts the same window the days cover.
184187 ActivityTotal int `json:"activity_total"`
185188}
@@ -323,6 +326,13 @@ func runProfileShow(c *Ctx, args []string) int {
323326 })
324327 }
325328
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
326336 var counts map[string]int
327337 if kind == "user" {
328338 counts, err = c.Store.ActivityByDay(id, ActivityWindow())
internal/httpd/routes.go +3
@@ -73,6 +73,9 @@ func (s *Server) Routes() []Route {
7373 Route{Method: "GET", Pattern: "/{owner}/activity.atom", Handler: s.ownerAtom},
7474 Route{Method: "GET", Pattern: "/{owner}/-/labels", Handler: s.orgLabels},
7575 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},
7679 Route{Method: "GET", Pattern: "/{owner}/{repo}/builds", Handler: s.builds},
7780 Route{Method: "GET", Pattern: "/{owner}/{repo}/badge/build.svg", Handler: s.buildBadge},
7881 Route{Method: "GET", Pattern: "/{owner}/{repo}/badge/build.png", Handler: s.buildBadgePNG},
internal/httpd/snippets.go added +115
@@ -0,0 +1,115 @@
1package httpd
2
3import (
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.
15func (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
25type snippetRow struct {
26 store.Snippet
27 Names string
28}
29
30func (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
65type snippetFileView struct {
66 Name string
67 Size int64
68 Lines int
69 Content string
70 HTML template.HTML
71}
72
73func (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.
102func (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) {
445445 Teams []teamView
446446 CanAdmin bool
447447 Self bool
448 Snippets int
448449 Notice string
449450 Feed string
450451 }{s.baseFor(viewer), name, d.Kind, profile, aboutHTML(profile),
451452 d.Repos, d.Members, d.Orgs,
452453 weeks, activityTotal, teams, canAdmin,
453454 d.Kind == "user" && viewer.ID != 0 && strings.EqualFold(viewer.Username, name),
455 d.Snippets,
454456 s.takeFlash(w, r), "/" + name + "/activity.atom"})
455457}
456458
internal/web/templates/owner.html +1
@@ -23,6 +23,7 @@
2323{{range .Repos}}{{template "reporow" .}}
2424{{else}}<li class="empty">no visible repositories</li>{{end}}
2525</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}}
2627
2728{{if .Self}}
2829<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 &lt;file&gt; &lt; 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}}