", passOpts{server: []string{"snippet", "file", "remove"}}),
),
),
```
- [ ] **Step 7: Read-only coverage entries**
In `e2e/readonly_test.go`, after the line `must("data\n", "release", "asset", "add", "alice/app", "v1", "a.txt")` add:
```go
snippetOut := must("hello\n", "snippet", "create", "a.txt", "--json")
snippetID := regexp.MustCompile(`"id":"([0-9a-f]{12})"`).FindStringSubmatch(snippetOut)[1]
```
Add `"regexp"` to the file's imports if it is not there. In the `readArgs` map add, beside the `release` rows:
```go
"snippet show": {snippetID},
"snippet list": {},
"snippet file get": {snippetID, "a.txt"},
```
- [ ] **Step 8: Build, vet, run the tests**
Run: `go build ./... && go vet ./... && go test ./internal/control ./internal/policy ./internal/config && go test ./e2e -run 'TestSnippets$|TestCLI$|TestReadOnlyCommandsWriteNothing$'`
Expected: all PASS. `TestCLI` proves every `snippet` command has a CLI row with a `stdinWhat`; `TestReadOnlyCommandsWriteNothing` proves the three reads write nothing.
If `TestSnippets` fails on the paged `next` cursor, check that `parsePageFlags` was called with `numeric=true` and that `trimPage` keys on `sn.ID`, not `sn.PublicID`.
- [ ] **Step 9: Commit**
```bash
git add internal/policy/snippets.go internal/config/config.go internal/control/snippet.go cmd/gitbay/main.go e2e/readonly_test.go e2e/snippet_test.go
git commit -m "control: snippet commands
create, show, list, edit, delete, and file set|get|remove. Content is
UTF-8 under limits.max_snippet_bytes per file, 64 files per snippet.
Private snippets are not-found to everyone but the owner and admins.
Ref #195"
```
---
### Task 3: Web read pages and the owner-page link
**Files:**
- Create: `internal/httpd/snippets.go`
- Create: `internal/web/templates/snippets.html`
- Create: `internal/web/templates/snippet.html`
- Modify: `internal/httpd/routes.go:74-75` (beside the `/{owner}/-/labels` routes)
- Modify: `internal/control/profile.go:165-185` (`ProfileOut`) and the `profile show` body near line 296
- Modify: `internal/httpd/web.go:436-466` (the `owner.html` page struct and its literal)
- Modify: `internal/web/templates/owner.html:21-25`
- Test: `e2e/snippetweb_test.go`
**Interfaces:**
- Consumes `store.SnippetByPublicID`, `store.SnippetFiles`, `store.SnippetFile`, `store.ListSnippets`, `store.CountSnippets`, `policy.CanReadSnippet`, `policy.CanWriteSnippet`, `highlight(path string, data []byte) template.HTML` in `internal/httpd/web.go`.
- Produces `ProfileOut.Snippets int` (`json:"snippets"`): the owner's snippets the caller may list (public, or all for the owner and admins). Produces the handlers `snippetsPage`, `snippetPage`, `snippetRaw` and the helper `snippetScope`, which Task 4's write handlers reuse.
- [ ] **Step 1: Write the failing e2e test (read half)**
Create `e2e/snippetweb_test.go`:
```go
package e2e
import (
"encoding/json"
"net/http"
"net/url"
"strings"
"testing"
)
func snippetIDFrom(t *testing.T, out string) string {
t.Helper()
var env struct {
Data struct {
ID string `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(out), &env); err != nil || env.Data.ID == "" {
t.Fatalf("snippet create: %s", out)
}
return env.Data.ID
}
// Snippet pages: the owner's list, one snippet with highlighted files, the
// raw route, the owner-page link, and 404 for what the viewer may not see.
func TestSnippetsWeb(t *testing.T) {
inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
aliceKey := inst.newKey(t, "alice")
bobKey := inst.newKey(t, "bob")
inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub", "--email", "bob@example.test", "--verified")
must := func(key, stdin string, args ...string) string {
t.Helper()
out, errOut, code := inst.ssh(t, key, stdin, args...)
if code != 0 {
t.Fatalf("%v: exit %d %s", args, code, errOut)
}
return out
}
public := snippetIDFrom(t, must(aliceKey, "package main\n", "snippet", "create", "main.go", "--visibility", "public", "--description", "'hello world'", "--json"))
unlisted := snippetIDFrom(t, must(aliceKey, "quiet\n", "snippet", "create", "q.txt", "--json"))
private := snippetIDFrom(t, must(aliceKey, "secret\n", "snippet", "create", "s.txt", "--visibility", "private", "--json"))
// Anonymous: the public list, the unlisted page by URL, 404 for private.
status, body := inst.get(t, "/alice/-/snippets")
if status != 200 || !strings.Contains(body, public) || strings.Contains(body, unlisted) || strings.Contains(body, private) {
t.Fatalf("anonymous list: %d\n%s", status, body)
}
status, body = inst.get(t, "/alice/-/snippets/"+public)
if status != 200 || !strings.Contains(body, "hello world") || !strings.Contains(body, `class="chroma"`) || !strings.Contains(body, "/raw/main.go") {
t.Fatalf("public page: %d\n%s", status, body)
}
if status, _ := inst.get(t, "/alice/-/snippets/"+unlisted); status != 200 {
t.Fatalf("unlisted page: %d", status)
}
if status, _ := inst.get(t, "/alice/-/snippets/"+private); status != 404 {
t.Fatalf("private page for anonymous: %d", status)
}
if status, _ := inst.get(t, "/bob/-/snippets/"+public); status != 404 {
t.Fatalf("id under the wrong owner: %d", status)
}
if status, _ := inst.get(t, "/nobody/-/snippets"); status != 404 {
t.Fatalf("list for a missing owner: %d", status)
}
// Raw is text/plain with nosniff, whatever the extension.
resp, err := http.Get(inst.base() + "/alice/-/snippets/" + public + "/raw/main.go")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != 200 || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/plain") || resp.Header.Get("X-Content-Type-Options") != "nosniff" {
t.Fatalf("raw headers: %d %v", resp.StatusCode, resp.Header)
}
if status, _ := inst.get(t, "/alice/-/snippets/" + public + "/raw/other.go"); status != 404 {
t.Fatalf("raw for a missing file: %d", status)
}
// The owner sees everything with visibility marks; the owner page links.
alice := inst.login(t, aliceKey)
status, body = browserGet(t, alice, inst.base()+"/alice/-/snippets")
if status != 200 || !strings.Contains(body, private) || !strings.Contains(body, ">private<") {
t.Fatalf("owner list: %d\n%s", status, body)
}
if status, body := browserGet(t, alice, inst.base()+"/alice/-/snippets/"+private); status != 200 || !strings.Contains(body, "secret") {
t.Fatalf("owner's private page: %d", status)
}
if status, body := inst.get(t, "/alice"); status != 200 || !strings.Contains(body, `href="/alice/-/snippets"`) {
t.Fatalf("owner page lacks the snippets link: %d", status)
}
// bob has no public snippets and is not the viewer: no link.
if status, body := inst.get(t, "/bob"); status != 200 || strings.Contains(body, `href="/bob/-/snippets"`) {
t.Fatalf("bob's page shows a snippets link with nothing to list: %d", status)
}
_ = url.Values{}
_ = bobKey
}
```
The last two lines keep the imports and `bobKey` used until Task 4 extends the test; Task 4 removes them.
- [ ] **Step 2: Run it to see it fail**
Run: `go test ./e2e -run 'TestSnippetsWeb$'`
Expected: FAIL at "anonymous list", status 404.
- [ ] **Step 3: Profile count**
In `internal/control/profile.go`, add to `ProfileOut` after `Repos`:
```go
// Snippets counts the owner's snippets the caller may list: public
// ones, or all of them for the owner and admins. Orgs own none.
Snippets int `json:"snippets"`
```
In the `profile show` body, after the loop that fills `d.Repos` and before the activity counts, add:
```go
if kind == "user" {
all := id == c.User.ID || c.User.IsAdmin
if d.Snippets, err = c.Store.CountSnippets(id, all); err != nil {
return c.fail(protocol.ExitFailure, "%v", err)
}
}
```
(`kind` and `id` are the variables the surrounding code already uses for the owner's kind and row id; read the function and use its names.)
- [ ] **Step 4: Routes**
In `internal/httpd/routes.go`, after the `/{owner}/-/milestones` route add:
```go
Route{Method: "GET", Pattern: "/{owner}/-/snippets", Handler: s.snippetsPage},
Route{Method: "GET", Pattern: "/{owner}/-/snippets/{id}", Handler: s.snippetPage},
Route{Method: "GET", Pattern: "/{owner}/-/snippets/{id}/raw/{name}", Handler: s.snippetRaw},
```
These are read routes, registered in every web mode; the literal `-` and `snippets` segments keep them from overlapping any `/{owner}/{repo}/...` pattern.
- [ ] **Step 5: Handlers**
Create `internal/httpd/snippets.go`:
```go
package httpd
import (
"bytes"
"html/template"
"net/http"
"gitbay.org/gitbay/internal/policy"
"gitbay.org/gitbay/internal/store"
)
// snippetScope resolves the owner and id in the URL for the viewer. A
// missing owner, an id under another owner, and a private snippet the
// viewer may not read are all the same 404.
func (s *Server) snippetScope(w http.ResponseWriter, r *http.Request) (store.Snippet, store.User, bool) {
viewer := s.viewer(r)
sn, err := s.st.SnippetByPublicID(r.PathValue("id"))
if err != nil || sn.OwnerName != r.PathValue("owner") || !policy.CanReadSnippet(viewer, sn) {
s.notFound(w, r)
return sn, viewer, false
}
return sn, viewer, true
}
type snippetRow struct {
store.Snippet
Names string
}
func (s *Server) snippetsPage(w http.ResponseWriter, r *http.Request) {
viewer := s.viewer(r)
owner, err := s.st.UserByUsername(r.PathValue("owner"))
if err != nil {
s.notFound(w, r)
return
}
self := viewer.ID != 0 && viewer.ID == owner.ID
all := self || viewer.IsAdmin
list, err := s.st.ListSnippets(owner.ID, all, 0, 0)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
rows := make([]snippetRow, 0, len(list))
for _, sn := range list {
var names bytes.Buffer
for i, f := range sn.Files {
if i > 0 {
names.WriteString(", ")
}
names.WriteString(f.Name)
}
rows = append(rows, snippetRow{sn, names.String()})
}
s.render(w, "snippets.html", struct {
basePage
Owner string
Self bool
All bool
Snippets []snippetRow
Notice string
}{s.baseFor(viewer), owner.Username, self, all, rows, s.takeFlash(w, r)})
}
type snippetFileView struct {
Name string
Size int64
Lines int
Content string
HTML template.HTML
}
func (s *Server) snippetPage(w http.ResponseWriter, r *http.Request) {
sn, viewer, ok := s.snippetScope(w, r)
if !ok {
return
}
files, err := s.st.SnippetFiles(sn.ID)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
views := make([]snippetFileView, 0, len(files))
for _, f := range files {
lines := bytes.Count(f.Content, []byte("\n"))
if len(f.Content) > 0 && f.Content[len(f.Content)-1] != '\n' {
lines++
}
views = append(views, snippetFileView{f.Name, f.Size, lines, string(f.Content), highlight(f.Name, f.Content)})
}
s.render(w, "snippet.html", struct {
basePage
Owner string
Snippet store.Snippet
Files []snippetFileView
CanWrite bool
Notice string
}{s.baseFor(viewer), sn.OwnerName, sn, views, policy.CanWriteSnippet(viewer, sn), s.takeFlash(w, r)})
}
// snippetRaw serves one file as text, inert on the forge's origin.
func (s *Server) snippetRaw(w http.ResponseWriter, r *http.Request) {
sn, _, ok := s.snippetScope(w, r)
if !ok {
return
}
f, err := s.st.SnippetFile(sn.ID, r.PathValue("name"))
if err != nil {
s.notFound(w, r)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Write(f.Content)
}
```
`s.viewer` reads the session cookie; in `view_only` mode there is never one, so the viewer is anonymous there without a mode check.
- [ ] **Step 6: Templates**
Create `internal/web/templates/snippets.html`:
```html
{{define "title"}}snippets · {{.Owner}}{{end}}
{{define "content"}}
{{if .Notice}}{{.Notice}}
{{end}}
{{if .Self}}new snippet · or gitbay snippet create <file> < file
{{end}}
{{if .Snippets}}
{{else}}No snippets yet.
{{end}}
{{end}}
```
Create `internal/web/templates/snippet.html` (the write forms come in Task 4; leave the `{{if .CanWrite}}` block out for now):
```html
{{define "title"}}{{if .Snippet.Description}}{{.Snippet.Description}}{{else}}{{.Snippet.PublicID}}{{end}} · {{.Owner}}{{end}}
{{define "content"}}
{{if .Snippet.Description}}{{.Snippet.Description}}
{{end}}
{{.Snippet.Visibility}} · updated {{.Snippet.UpdatedAt}} · gitbay snippet show {{.Snippet.PublicID}}
{{if .Notice}}{{.Notice}}
{{end}}
{{range .Files}}
{{.Lines}} lines · {{.Size}} bytes
{{.HTML}}
{{end}}
{{end}}
```
- [ ] **Step 7: Owner page link**
In `internal/httpd/web.go`, in the `owner.html` page struct add `Snippets int` after `Self bool`, and in the literal pass `d.Snippets` after the `Self` expression (the `d.Kind == "user" && ...` line). In `internal/web/templates/owner.html`, after the `` that closes the repository list, add:
```html
{{if or .Snippets .Self}}snippets{{if .Snippets}} {{.Snippets}}{{end}}
{{end}}
```
- [ ] **Step 8: Build and run the tests**
Run: `go build ./... && go vet ./... && go test ./internal/httpd && go test ./e2e -run 'TestSnippetsWeb$'`
Expected: PASS. `internal/httpd`'s template tests check the new templates for unlabeled inputs (none yet) and route/reserved-name agreement (nothing new at the top level).
- [ ] **Step 9: Commit**
```bash
git add internal/httpd/snippets.go internal/httpd/routes.go internal/httpd/web.go internal/control/profile.go internal/web/templates/snippets.html internal/web/templates/snippet.html internal/web/templates/owner.html e2e/snippetweb_test.go
git commit -m "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"
```
---
### Task 4: Web writes
**Files:**
- Modify: `internal/httpd/snippets.go` (append the write handlers)
- Modify: `internal/httpd/routes.go` (the account-mode block, beside the `/bookmarks` routes)
- Modify: `internal/web/templates/snippet.html` (the `{{if .CanWrite}}` forms)
- Create: `internal/web/templates/snippetnew.html`
- Test: `e2e/snippetweb_test.go` (extend)
**Interfaces:**
- Consumes `snippetScope`, `control.SnippetOut`, `s.dispatchIntoStdin`, `s.runControlCode`, `s.runControlStdinCode`, `s.done`, `s.setFlash`, `statusForExit`.
- Produces the five POST handlers and the GET form named in the constraints.
- [ ] **Step 1: Extend the e2e test**
In `e2e/snippetweb_test.go`, replace the two placeholder lines (`_ = url.Values{}` and `_ = bobKey`) with:
```go
// The create form makes a snippet through snippet create.
status, body = browserPost(t, alice, inst.base()+"/alice/-/snippets/new", url.Values{
"name": {"notes.md"}, "description": {"from the browser"}, "visibility": {"public"}, "content": {"# notes\n"}})
if status != 200 || !strings.Contains(body, "from the browser") || !strings.Contains(body, "notes.md") {
t.Fatalf("create form: %d\n%s", status, body)
}
var listed struct {
Data []struct {
ID string `json:"id"`
Description string `json:"description"`
} `json:"data"`
}
json.Unmarshal([]byte(must(aliceKey, "", "snippet", "list", "--json")), &listed)
created := ""
for _, sn := range listed.Data {
if sn.Description == "from the browser" {
created = sn.ID
}
}
if created == "" {
t.Fatalf("created from the web, not listed: %+v", listed.Data)
}
if status, _ := browserGet(t, alice, inst.base()+"/bob/-/snippets/new"); status != 404 {
t.Fatalf("new form under another owner: %d", status)
}
// The file form replaces a file and adds one; remove drops it.
page := inst.base() + "/alice/-/snippets/" + created
if status, _ := browserPost(t, alice, page+"/file", url.Values{"name": {"notes.md"}, "content": {"# changed\n"}}); status != 200 {
t.Fatal("file replace failed")
}
if got := must(aliceKey, "", "snippet", "file", "get", created, "notes.md"); got != "# changed\n" {
t.Fatalf("after web replace: %q", got)
}
if status, _ := browserPost(t, alice, page+"/file", url.Values{"name": {"b.txt"}, "content": {"b\n"}}); status != 200 {
t.Fatal("file add failed")
}
if status, _ := browserPost(t, alice, page+"/file/remove", url.Values{"name": {"b.txt"}}); status != 200 {
t.Fatal("file remove failed")
}
if _, _, code := inst.ssh(t, aliceKey, "", "snippet", "file", "get", created, "b.txt"); code != 3 {
t.Fatalf("b.txt after web remove: exit %d", code)
}
// A refusal comes back on the page as a message, not a bare error.
_, body = browserPost(t, alice, page+"/file/remove", url.Values{"name": {"notes.md"}})
if !strings.Contains(body, `class="error"`) || !strings.Contains(body, "at least one file") {
t.Fatalf("last-file refusal on the page:\n%s", body)
}
// Edit changes visibility; delete removes.
if status, _ := browserPost(t, alice, page+"/edit", url.Values{"description": {"renamed"}, "visibility": {"private"}}); status != 200 {
t.Fatal("edit failed")
}
if status, _ := inst.get(t, "/alice/-/snippets/"+created); status != 404 {
t.Fatalf("private after web edit, anonymous: %d", status)
}
// bob cannot write alice's snippet from the browser either.
bob := inst.login(t, bobKey)
if status, _ := browserPost(t, bob, inst.base()+"/alice/-/snippets/"+public+"/edit", url.Values{"description": {"x"}, "visibility": {"public"}}); status != 403 {
t.Fatalf("bob editing alice's snippet: %d", status)
}
if status, _ := browserPost(t, alice, page+"/delete", nil); status != 200 {
t.Fatal("delete failed")
}
if _, _, code := inst.ssh(t, aliceKey, "", "snippet", "show", created); code != 3 {
t.Fatalf("after web delete: exit %d", code)
}
```
`browserPost` follows redirects, so a successful form lands on the page it redirects to with status 200 and the page's body. The id of the snippet the form made comes from `snippet list --json`, matched by its description.
- [ ] **Step 2: Run it to see it fail**
Run: `go test ./e2e -run 'TestSnippetsWeb$'`
Expected: FAIL at "create form" with 404 or 405.
- [ ] **Step 3: Routes**
In `internal/httpd/routes.go`, inside the `web.mode == "accounts"` block, after the `/bookmarks` GET route add:
```go
Route{Method: "GET", Pattern: "/{owner}/-/snippets/new", Handler: s.requireUser(s.snippetNewForm)},
Route{Method: "POST", Pattern: "/{owner}/-/snippets/new", Mutating: true,
Handler: s.checkOrigin(s.requireUser(s.snippetNewSubmit))},
Route{Method: "POST", Pattern: "/{owner}/-/snippets/{id}/edit", Mutating: true,
Handler: s.checkOrigin(s.requireUser(s.snippetEditSubmit))},
Route{Method: "POST", Pattern: "/{owner}/-/snippets/{id}/delete", Mutating: true,
Handler: s.checkOrigin(s.requireUser(s.snippetDeleteSubmit))},
Route{Method: "POST", Pattern: "/{owner}/-/snippets/{id}/file", Mutating: true,
Handler: s.checkOrigin(s.requireUser(s.snippetFileSubmit))},
Route{Method: "POST", Pattern: "/{owner}/-/snippets/{id}/file/remove", Mutating: true,
Handler: s.checkOrigin(s.requireUser(s.snippetFileRemoveSubmit))},
```
`GET /{owner}/-/snippets/new` and `GET /{owner}/-/snippets/{id}` both match `/x/-/snippets/new`; the literal segment is more specific, so the mux picks the form.
- [ ] **Step 4: Handlers**
Append to `internal/httpd/snippets.go` (add `"strings"`, `"gitbay.org/gitbay/internal/control"` and `"gitbay.org/gitbay/internal/protocol"` to its imports):
```go
// snippetNewForm is the owner's own page only: the URL names the owner
// and a snippet cannot be created for someone else.
func (s *Server) snippetNewForm(w http.ResponseWriter, r *http.Request, u store.User) {
if r.PathValue("owner") != u.Username {
s.notFound(w, r)
return
}
s.render(w, "snippetnew.html", struct {
basePage
Owner string
}{s.baseFor(u), u.Username})
}
func (s *Server) snippetNewSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
if r.PathValue("owner") != u.Username {
s.notFound(w, r)
return
}
argv := []string{"snippet", "create", strings.TrimSpace(r.FormValue("name")),
"--description", strings.TrimSpace(r.FormValue("description")),
"--visibility", r.FormValue("visibility")}
var out control.SnippetOut
code, msg := s.dispatchIntoStdin(u, argv, r.FormValue("content"), &out)
if code != protocol.ExitOK {
http.Error(w, msg, statusForExit(code))
return
}
http.Redirect(w, r, "/"+u.Username+"/-/snippets/"+out.ID, http.StatusSeeOther)
}
// snippetAction runs a write on the snippet in the URL and returns to
// its page with the message, or to the list after a delete. A snippet
// the viewer may not read is the 404 page, as on every read.
func (s *Server) snippetAction(w http.ResponseWriter, r *http.Request, u store.User, argv []string, stdin string, dest string) {
sn, _, ok := s.snippetScope(w, r)
if !ok {
return
}
if dest == "" {
dest = "/" + sn.OwnerName + "/-/snippets/" + sn.PublicID
}
back := func(w http.ResponseWriter, r *http.Request, msg string) {
s.setFlash(w, msg)
http.Redirect(w, r, dest, http.StatusSeeOther)
}
var msg string
var code int
if stdin == "" {
_, msg, code = s.runControlCode(u, argv)
} else {
msg, code = s.runControlStdinCode(u, argv, stdin)
}
if code == protocol.ExitDenied {
http.Error(w, msg, http.StatusForbidden)
return
}
s.done(w, r, code, msg, back)
}
func (s *Server) snippetEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
s.snippetAction(w, r, u, []string{"snippet", "edit", r.PathValue("id"),
"--description", strings.TrimSpace(r.FormValue("description")),
"--visibility", r.FormValue("visibility")}, "", "")
}
func (s *Server) snippetDeleteSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
s.snippetAction(w, r, u, []string{"snippet", "delete", r.PathValue("id")}, "",
"/"+r.PathValue("owner")+"/-/snippets")
}
// An empty textarea reaches the command as empty stdin, which it refuses;
// the message lands on the page like any other.
func (s *Server) snippetFileSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
s.snippetAction(w, r, u, []string{"snippet", "file", "set", r.PathValue("id"), strings.TrimSpace(r.FormValue("name"))},
r.FormValue("content"), "")
}
func (s *Server) snippetFileRemoveSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
s.snippetAction(w, r, u, []string{"snippet", "file", "remove", r.PathValue("id"), strings.TrimSpace(r.FormValue("name"))}, "", "")
}
```
The denied branch answers 403 rather than a redirect because the viewer can read the page but not change it; the e2e test asserts it. Browsers send `\r\n` from a textarea; the command stores what it receives, which is what the raw route serves back. Do not normalise.
- [ ] **Step 5: Templates**
Create `internal/web/templates/snippetnew.html`:
```html
{{define "title"}}new snippet · {{.Owner}}{{end}}
{{define "content"}}
New snippet
{{end}}
```
In `internal/web/templates/snippet.html`, inside the `{{range .Files}}` section after `{{.HTML}}
`, add:
```html
{{if $.CanWrite}}edit {{.Name}}
{{end}}
```
and after the `{{end}}` that closes the range, before the final `{{end}}`:
```html
{{if .CanWrite}}
add a file
settings
{{end}}
```
If `.editbox` or `.commentform` render poorly beside `.code`, add a `.snippetfile { margin-bottom: 1.5rem }` rule to `internal/web/static/style.css`; nothing more.
- [ ] **Step 6: Build and run the tests**
Run: `go build ./... && go vet ./... && go test ./internal/httpd && go test ./e2e -run 'TestSnippetsWeb$'`
Expected: PASS. `internal/httpd` carries the structural checks: `TestMutatingRoutesRequireCheckOrigin` (every `Mutating` route is wrapped in `checkOrigin`), `TestViewOnlyHasNoMutatingRoutes` (the POST routes sit inside the accounts block), and the input-label test on `snippetnew.html` and the new forms.
- [ ] **Step 7: Commit**
```bash
git add internal/httpd/snippets.go internal/httpd/routes.go internal/web/templates/snippet.html internal/web/templates/snippetnew.html e2e/snippetweb_test.go
git commit -m "web: create, edit and delete snippets
Every form dispatches the snippet command the CLI runs.
Ref #195"
```
---
### Task 5: Documentation and changelog
**Files:**
- Modify: `.gitbay/wiki/Users.org` (a `* Snippets` section after `* Pages`, before `* Browser sessions`)
- Modify: `.gitbay/wiki/Parity.org` (rows after `release asset remove`)
- Modify: `.gitbay/wiki/Admin.org:116` (the `[limits]` list)
- Modify: `CHANGELOG.org` (a new top entry)
- [ ] **Step 1: Users.org**
Insert before `* Browser sessions`:
```org
* Snippets
A snippet is one or more named text files you own outside any
repository, for a log or a fragment shared by URL. Create one from a
file on stdin; the reply is the id and the URL:
#+begin_src sh
gitbay snippet create build.log --description "failing build" < build.log
gitbay snippet file set notes.txt < notes.txt # add or replace a file
gitbay snippet file get build.log > build.log
gitbay snippet edit --visibility public
gitbay snippet list # yours
gitbay snippet list # their public ones
gitbay snippet delete
#+end_src
Visibility is =public= (listed on your page), =unlisted= (anyone with
the URL, listed nowhere; the default) or =private= (you alone; not
found to everyone else). Files are text, valid UTF-8, each under the
instance's =max_snippet_bytes=, at most 64 per snippet. A snippet keeps
at least one file. There is no history: setting a file replaces it.
On the web, =//-/snippets= lists yours, each snippet page renders
its files with a raw link per file, and the same page creates, edits
and deletes through the commands above.
```
- [ ] **Step 2: Parity.org**
After the `release asset remove` row add:
```org
| snippet create, edit, delete | yes | yes | no |
| snippet show, list | yes | yes | no |
| snippet file set, get, remove | yes | yes | no |
```
Match the table's column alignment by hand; org tables tolerate ragged cells but the page is read raw too.
- [ ] **Step 3: Admin.org**
After the `max_asset_bytes` line in `** [limits]` add:
```org
- =max_snippet_bytes= (1MB) — cap per snippet file.
```
- [ ] **Step 4: CHANGELOG.org**
Before `* v1.19.0 — 2026-09-11` add:
```org
* v1.20.0 — unreleased
Snippets (#195): named text files a user owns outside any repository,
shared by URL and edited in place.
- Migration 0053: =snippets= and =snippet_files=.
- =snippet create|show|list|edit|delete= and =snippet file
set|get|remove=. Files are UTF-8 under =limits.max_snippet_bytes=
(1MB), at most 64 per snippet; a snippet keeps at least one.
Visibility =public=, =unlisted= (default) or =private=; a private
snippet is not found to everyone but its owner and admins.
- Web: =//-/snippets= lists, each snippet page renders its
files with a raw route per file, and the owner creates, edits and
deletes from the page through the same commands. The owner page
links to the list.
```
- [ ] **Step 5: Commit**
```bash
git add .gitbay/wiki/Users.org .gitbay/wiki/Parity.org .gitbay/wiki/Admin.org CHANGELOG.org
git commit -m "wiki, CHANGELOG: snippets
Closes #195"
```
---
### Task 6: Merge request
- [ ] **Step 1: Push and open the MR**
```bash
git push -u origin snippets
gitbay mr create --source snippets --target main --title "Snippets (#195)" --file - <<'EOF'
Named text files a user owns outside any repository, shared by URL and
edited in place. Spec: docs/specs/2026-09-11-snippets-design.md.
Migration 0053. Commands snippet create|show|list|edit|delete and
snippet file set|get|remove; web under /{owner}/-/snippets with forms
dispatching the same commands. New limit max_snippet_bytes (1MB).
Closes #195
EOF
```
- [ ] **Step 2: Wait for CI, then merge**
Check `gitbay build list --json` until the build for the branch head succeeds; read `gitbay build log ` on failure and fix on the branch. Then:
```bash
gitbay mr merge --strategy ff
git push origin --delete snippets
```
and remove the worktree and branch locally after the merge lands.