Commit 7df6e615d1

7df6e615d11f75e66f6de5541e99a351d63f15e1

parent: dc95376e5c

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-11 19:30 UTC

docs: snippets implementation plan

Ref #195
docs/plans/2026-09-11-snippets.md added +1910
@@ -0,0 +1,1910 @@
1# Snippets: implementation plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** A snippet a user owns, shares by URL, and edits in place: one or more named text files, a description, and a visibility. Closes #195.
6
7**Architecture:** Two new tables (migration 0053) hold snippets and their files; content sits in SQLite as a BLOB. Eight `snippet` control commands in `internal/control/snippet.go` are the only write path; the CLI, the JSON API and the web forms dispatch into them. Read rules live in `internal/policy/snippets.go`. The web renders under `/{owner}/-/snippets`, the pattern `/{owner}/-/labels` set.
8
9**Tech Stack:** Go, SQLite via modernc (hand-written SQL, no ORM), Go `html/template`, chroma through the existing `highlight`, the control registry in `internal/control`, the e2e harness in `e2e/`.
10
11**Spec:** `docs/specs/2026-09-11-snippets-design.md`
12
13## Global Constraints
14
15- Every capability lands as a control command first; the CLI, web and API dispatch into it. New commands need a `pass()` row in `cmd/gitbay/main.go` (`TestCLI` in `e2e/cli_test.go` enforces this) and, if `ReadOnly`, a row in `readArgs` in `e2e/readonly_test.go` (`TestReadOnlyCommandsWriteNothing` enforces that).
16- A command that reads stdin sets `ReadsStdin: true`, or `control.go` swaps in an empty reader and `--file -` stores nothing without an error.
17- Hand-written SQL only. Migrations are `internal/store/migrations/NNNN_name.up.sql` and `.down.sql`, embedded, run one per transaction; `TestMigrateUpDown` runs both directions.
18- Private things return not-found (exit 3, HTTP 404), never a denial that confirms they exist.
19- Every `<input>` and `<textarea>` a person types into carries an `aria-label` (`internal/httpd/inputlabels_test.go`). One `<h1>` per page.
20- Never mention an assistant or model anywhere: commit messages, comments, docs.
21- Commit messages reference the issue: `Ref #195` on each task, `Closes #195` on the last.
22- Run locally: `go build ./... && go vet ./...` and the unit tests of the touched packages. Run at most the one e2e test you write (`go test ./e2e -run 'TestSnippets$'`); CI on bay1 runs the full suite.
23- Style: plain sentences in comments, no dramatic framing. Match the surrounding code. Comments in stores and handlers are one or two lines saying why, as the neighbours do.
24- Work on branch `snippets` in the worktree `../gitbay-snippets`, which already holds the spec.
25
26Names used across tasks, fixed here so the tasks agree:
27
28- Store: `store.Snippet`, `store.SnippetFile`, `CreateSnippet`, `SnippetByPublicID`, `SnippetFiles`, `SnippetFile`, `ListSnippets`, `CountSnippets`, `UpdateSnippet`, `DeleteSnippet`, `SetSnippetFile`, `RemoveSnippetFile`.
29- Policy: `policy.CanReadSnippet`, `policy.CanWriteSnippet`.
30- Config: `Limits.MaxSnippetBytes` (`max_snippet_bytes`, default `1 << 20`).
31- Control: exported `control.SnippetOut`, `control.SnippetFileOut`; the constant `maxSnippetFiles = 64`.
32- Web: handlers `snippetsPage`, `snippetPage`, `snippetRaw`, `snippetNewForm`, `snippetNewSubmit`, `snippetEditSubmit`, `snippetDeleteSubmit`, `snippetFileSubmit`, `snippetFileRemoveSubmit`; templates `snippets.html`, `snippet.html`, `snippetnew.html`.
33
34---
35
36### Task 1: Migration 0053 and the store
37
38**Files:**
39- Create: `internal/store/migrations/0053_snippets.up.sql`
40- Create: `internal/store/migrations/0053_snippets.down.sql`
41- Create: `internal/store/snippets.go`
42- Test: `internal/store/snippets_test.go`
43
44**Interfaces:**
45- Produces the tables `snippets` and `snippet_files` as in the spec.
46- Produces:
47
48```go
49type Snippet struct {
50 ID int64
51 PublicID string
52 OwnerID int64
53 OwnerName string
54 Description string
55 Visibility string // public | unlisted | private
56 CreatedAt string
57 UpdatedAt string
58 Files []SnippetFile // names and sizes; Content is filled by SnippetFiles and SnippetFile only
59}
60type SnippetFile struct {
61 Name string
62 Size int64
63 Content []byte
64}
65func (s *Store) CreateSnippet(ownerID int64, publicID, description, visibility, name string, content []byte) (int64, error) // ErrExists on a public_id collision
66func (s *Store) SnippetByPublicID(publicID string) (Snippet, error) // ErrNotFound; Files carry Name and Size
67func (s *Store) SnippetFiles(id int64) ([]SnippetFile, error) // with Content, by name
68func (s *Store) SnippetFile(id int64, name string) (SnippetFile, error) // ErrNotFound
69func (s *Store) ListSnippets(ownerID int64, all bool, limit int, afterID int64) ([]Snippet, error) // newest first; all=false is public only; afterID=0 from the start
70func (s *Store) CountSnippets(ownerID int64, all bool) (int, error)
71func (s *Store) UpdateSnippet(id int64, description, visibility string) error
72func (s *Store) DeleteSnippet(id int64) error
73func (s *Store) SetSnippetFile(id int64, name string, content []byte) error // insert or replace; touches updated_at
74func (s *Store) RemoveSnippetFile(id int64, name string) error // ErrNotFound when absent; touches updated_at
75```
76
77- [ ] **Step 1: Write the failing store test**
78
79Create `internal/store/snippets_test.go`:
80
81```go
82package store
83
84import (
85 "errors"
86 "testing"
87)
88
89func TestSnippets(t *testing.T) {
90 s := open(t)
91 alice, err := s.CreateUser("alice", false)
92 if err != nil {
93 t.Fatal(err)
94 }
95 id, err := s.CreateSnippet(alice, "abcdef012345", "a log", "unlisted", "build.log", []byte("ok\n"))
96 if err != nil {
97 t.Fatal(err)
98 }
99 if _, err := s.CreateSnippet(alice, "abcdef012345", "", "public", "x", []byte("x")); !errors.Is(err, ErrExists) {
100 t.Fatalf("duplicate public id: %v", err)
101 }
102 sn, err := s.SnippetByPublicID("abcdef012345")
103 if err != nil {
104 t.Fatal(err)
105 }
106 if sn.ID != id || sn.OwnerName != "alice" || sn.Visibility != "unlisted" || sn.Description != "a log" {
107 t.Fatalf("snippet: %+v", sn)
108 }
109 if len(sn.Files) != 1 || sn.Files[0].Name != "build.log" || sn.Files[0].Size != 3 || sn.Files[0].Content != nil {
110 t.Fatalf("files on lookup: %+v", sn.Files)
111 }
112
113 // Set adds, then replaces; remove drops; the file read carries content.
114 if err := s.SetSnippetFile(id, "notes.txt", []byte("one\n")); err != nil {
115 t.Fatal(err)
116 }
117 if err := s.SetSnippetFile(id, "notes.txt", []byte("two\n")); err != nil {
118 t.Fatal(err)
119 }
120 f, err := s.SnippetFile(id, "notes.txt")
121 if err != nil || string(f.Content) != "two\n" || f.Size != 4 {
122 t.Fatalf("file after replace: %+v %v", f, err)
123 }
124 files, err := s.SnippetFiles(id)
125 if err != nil || len(files) != 2 || files[0].Name != "build.log" || string(files[1].Content) != "two\n" {
126 t.Fatalf("files: %+v %v", files, err)
127 }
128 if err := s.RemoveSnippetFile(id, "notes.txt"); err != nil {
129 t.Fatal(err)
130 }
131 if err := s.RemoveSnippetFile(id, "notes.txt"); !errors.Is(err, ErrNotFound) {
132 t.Fatalf("remove missing file: %v", err)
133 }
134 if _, err := s.SnippetFile(id, "notes.txt"); !errors.Is(err, ErrNotFound) {
135 t.Fatalf("read removed file: %v", err)
136 }
137
138 // Listing: public only unless all; newest first; keyset by id.
139 pub, err := s.CreateSnippet(alice, "000000000001", "", "public", "a", []byte("a"))
140 if err != nil {
141 t.Fatal(err)
142 }
143 if _, err := s.CreateSnippet(alice, "000000000002", "", "private", "b", []byte("b")); err != nil {
144 t.Fatal(err)
145 }
146 got, err := s.ListSnippets(alice, false, 0, 0)
147 if err != nil || len(got) != 1 || got[0].ID != pub {
148 t.Fatalf("public list: %+v %v", got, err)
149 }
150 got, err = s.ListSnippets(alice, true, 0, 0)
151 if err != nil || len(got) != 3 || got[0].PublicID != "000000000002" || got[2].ID != id {
152 t.Fatalf("all list: %+v %v", got, err)
153 }
154 got, err = s.ListSnippets(alice, true, 2, got[0].ID)
155 if err != nil || len(got) != 2 || got[0].ID != pub {
156 t.Fatalf("paged list: %+v %v", got, err)
157 }
158 if n, err := s.CountSnippets(alice, false); err != nil || n != 1 {
159 t.Fatalf("public count: %d %v", n, err)
160 }
161 if n, err := s.CountSnippets(alice, true); err != nil || n != 3 {
162 t.Fatalf("all count: %d %v", n, err)
163 }
164
165 // Update, delete, and the owner cascade.
166 if err := s.UpdateSnippet(id, "renamed", "public"); err != nil {
167 t.Fatal(err)
168 }
169 sn, _ = s.SnippetByPublicID("abcdef012345")
170 if sn.Description != "renamed" || sn.Visibility != "public" {
171 t.Fatalf("after update: %+v", sn)
172 }
173 if err := s.DeleteSnippet(id); err != nil {
174 t.Fatal(err)
175 }
176 if _, err := s.SnippetByPublicID("abcdef012345"); !errors.Is(err, ErrNotFound) {
177 t.Fatalf("after delete: %v", err)
178 }
179 if err := s.DeleteUser(alice); err != nil {
180 t.Fatal(err)
181 }
182 var n int
183 if err := s.DB.QueryRow("SELECT COUNT(*) FROM snippet_files").Scan(&n); err != nil || n != 0 {
184 t.Fatalf("files after user delete: %d %v", n, err)
185 }
186}
187```
188
189- [ ] **Step 2: Run it to see it fail**
190
191Run: `go test ./internal/store -run TestSnippets`
192Expected: compile error, `s.CreateSnippet undefined`.
193
194- [ ] **Step 3: Write the migration**
195
196`internal/store/migrations/0053_snippets.up.sql`:
197
198```sql
199-- Snippets: named text files a user owns and shares by URL, outside any
200-- repository. public_id is the opaque id in URLs and commands.
201CREATE TABLE snippets (
202 id INTEGER PRIMARY KEY,
203 public_id TEXT NOT NULL UNIQUE,
204 owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
205 description TEXT NOT NULL DEFAULT '',
206 visibility TEXT NOT NULL CHECK (visibility IN ('public','unlisted','private')),
207 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
208 updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
209);
210CREATE INDEX snippets_owner ON snippets(owner_id, id);
211
212CREATE TABLE snippet_files (
213 snippet_id INTEGER NOT NULL REFERENCES snippets(id) ON DELETE CASCADE,
214 name TEXT NOT NULL,
215 content BLOB NOT NULL,
216 size INTEGER NOT NULL,
217 PRIMARY KEY (snippet_id, name)
218);
219```
220
221`internal/store/migrations/0053_snippets.down.sql`:
222
223```sql
224DROP TABLE snippet_files;
225DROP TABLE snippets;
226```
227
228- [ ] **Step 4: Write the store**
229
230Create `internal/store/snippets.go`:
231
232```go
233package store
234
235import (
236 "database/sql"
237 "errors"
238)
239
240type Snippet struct {
241 ID int64
242 PublicID string
243 OwnerID int64
244 OwnerName string
245 Description string
246 Visibility string // public | unlisted | private
247 CreatedAt string
248 UpdatedAt string
249 // Files carries names and sizes. Content is filled by SnippetFiles and
250 // SnippetFile only, so a listing does not read every body.
251 Files []SnippetFile
252}
253
254type SnippetFile struct {
255 Name string
256 Size int64
257 Content []byte
258}
259
260const snippetSelect = `
261 SELECT s.id, s.public_id, s.owner_id, u.username, s.description, s.visibility, s.created_at, s.updated_at
262 FROM snippets s JOIN users u ON u.id = s.owner_id`
263
264func scanSnippet(row interface{ Scan(...any) error }) (Snippet, error) {
265 var sn Snippet
266 err := row.Scan(&sn.ID, &sn.PublicID, &sn.OwnerID, &sn.OwnerName, &sn.Description, &sn.Visibility, &sn.CreatedAt, &sn.UpdatedAt)
267 return sn, err
268}
269
270// CreateSnippet inserts the snippet and its first file in one transaction.
271// A public_id collision is ErrExists so the caller can draw another.
272func (s *Store) CreateSnippet(ownerID int64, publicID, description, visibility, name string, content []byte) (int64, error) {
273 tx, err := s.DB.Begin()
274 if err != nil {
275 return 0, err
276 }
277 defer tx.Rollback()
278 res, err := tx.Exec(
279 "INSERT INTO snippets (public_id, owner_id, description, visibility) VALUES (?, ?, ?, ?)",
280 publicID, ownerID, description, visibility)
281 if err != nil {
282 if isUniqueErr(err) {
283 return 0, ErrExists
284 }
285 return 0, err
286 }
287 id, err := res.LastInsertId()
288 if err != nil {
289 return 0, err
290 }
291 if _, err := tx.Exec("INSERT INTO snippet_files (snippet_id, name, content, size) VALUES (?, ?, ?, ?)",
292 id, name, content, len(content)); err != nil {
293 return 0, err
294 }
295 return id, tx.Commit()
296}
297
298func (s *Store) SnippetByPublicID(publicID string) (Snippet, error) {
299 sn, err := scanSnippet(s.DB.QueryRow(snippetSelect+" WHERE s.public_id = ?", publicID))
300 if errors.Is(err, sql.ErrNoRows) {
301 return sn, ErrNotFound
302 }
303 if err != nil {
304 return sn, err
305 }
306 sn.Files, err = s.snippetFileNames(sn.ID)
307 return sn, err
308}
309
310func (s *Store) snippetFileNames(id int64) ([]SnippetFile, error) {
311 rows, err := s.DB.Query("SELECT name, size FROM snippet_files WHERE snippet_id = ? ORDER BY name", id)
312 if err != nil {
313 return nil, err
314 }
315 defer rows.Close()
316 var out []SnippetFile
317 for rows.Next() {
318 var f SnippetFile
319 if err := rows.Scan(&f.Name, &f.Size); err != nil {
320 return nil, err
321 }
322 out = append(out, f)
323 }
324 return out, rows.Err()
325}
326
327// SnippetFiles returns every file with its content, by name.
328func (s *Store) SnippetFiles(id int64) ([]SnippetFile, error) {
329 rows, err := s.DB.Query("SELECT name, size, content FROM snippet_files WHERE snippet_id = ? ORDER BY name", id)
330 if err != nil {
331 return nil, err
332 }
333 defer rows.Close()
334 var out []SnippetFile
335 for rows.Next() {
336 var f SnippetFile
337 if err := rows.Scan(&f.Name, &f.Size, &f.Content); err != nil {
338 return nil, err
339 }
340 out = append(out, f)
341 }
342 return out, rows.Err()
343}
344
345func (s *Store) SnippetFile(id int64, name string) (SnippetFile, error) {
346 var f SnippetFile
347 err := s.DB.QueryRow("SELECT name, size, content FROM snippet_files WHERE snippet_id = ? AND name = ?", id, name).
348 Scan(&f.Name, &f.Size, &f.Content)
349 if errors.Is(err, sql.ErrNoRows) {
350 return f, ErrNotFound
351 }
352 return f, err
353}
354
355// ListSnippets lists an owner's snippets newest first. all=false keeps
356// public ones only. afterID is the keyset cursor: rows older than it.
357// Ids grow with creation, so ordering by id is creation order.
358func (s *Store) ListSnippets(ownerID int64, all bool, limit int, afterID int64) ([]Snippet, error) {
359 q := snippetSelect + " WHERE s.owner_id = ?"
360 args := []any{ownerID}
361 if !all {
362 q += " AND s.visibility = 'public'"
363 }
364 if afterID > 0 {
365 q += " AND s.id < ?"
366 args = append(args, afterID)
367 }
368 q += " ORDER BY s.id DESC"
369 if limit > 0 {
370 q += " LIMIT ?"
371 args = append(args, limit)
372 }
373 rows, err := s.DB.Query(q, args...)
374 if err != nil {
375 return nil, err
376 }
377 defer rows.Close()
378 var out []Snippet
379 for rows.Next() {
380 sn, err := scanSnippet(rows)
381 if err != nil {
382 return nil, err
383 }
384 out = append(out, sn)
385 }
386 if err := rows.Err(); err != nil {
387 return nil, err
388 }
389 // One query per row for the names; pages are at most 200 rows.
390 for i := range out {
391 if out[i].Files, err = s.snippetFileNames(out[i].ID); err != nil {
392 return nil, err
393 }
394 }
395 return out, nil
396}
397
398func (s *Store) CountSnippets(ownerID int64, all bool) (int, error) {
399 q := "SELECT COUNT(*) FROM snippets WHERE owner_id = ?"
400 if !all {
401 q += " AND visibility = 'public'"
402 }
403 var n int
404 err := s.DB.QueryRow(q, ownerID).Scan(&n)
405 return n, err
406}
407
408func (s *Store) UpdateSnippet(id int64, description, visibility string) error {
409 _, err := s.DB.Exec(
410 "UPDATE snippets SET description = ?, visibility = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
411 description, visibility, id)
412 return err
413}
414
415func (s *Store) DeleteSnippet(id int64) error {
416 _, err := s.DB.Exec("DELETE FROM snippets WHERE id = ?", id)
417 return err
418}
419
420// SetSnippetFile adds the file or replaces one of the same name.
421func (s *Store) SetSnippetFile(id int64, name string, content []byte) error {
422 tx, err := s.DB.Begin()
423 if err != nil {
424 return err
425 }
426 defer tx.Rollback()
427 if _, err := tx.Exec(`INSERT INTO snippet_files (snippet_id, name, content, size) VALUES (?, ?, ?, ?)
428 ON CONFLICT (snippet_id, name) DO UPDATE SET content = excluded.content, size = excluded.size`,
429 id, name, content, len(content)); err != nil {
430 return err
431 }
432 if _, err := tx.Exec("UPDATE snippets SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", id); err != nil {
433 return err
434 }
435 return tx.Commit()
436}
437
438func (s *Store) RemoveSnippetFile(id int64, name string) error {
439 tx, err := s.DB.Begin()
440 if err != nil {
441 return err
442 }
443 defer tx.Rollback()
444 res, err := tx.Exec("DELETE FROM snippet_files WHERE snippet_id = ? AND name = ?", id, name)
445 if err != nil {
446 return err
447 }
448 if n, _ := res.RowsAffected(); n == 0 {
449 return ErrNotFound
450 }
451 if _, err := tx.Exec("UPDATE snippets SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", id); err != nil {
452 return err
453 }
454 return tx.Commit()
455}
456```
457
458- [ ] **Step 5: Run the store tests**
459
460Run: `go test ./internal/store`
461Expected: PASS, including `TestMigrateUpDown` (the down file drops both tables) and `TestSnippets`.
462
463- [ ] **Step 6: Commit**
464
465```bash
466git add internal/store/migrations/0053_snippets.up.sql internal/store/migrations/0053_snippets.down.sql internal/store/snippets.go internal/store/snippets_test.go
467git commit -m "store: snippets and snippet_files (migration 0053)
468
469Ref #195"
470```
471
472---
473
474### Task 2: Policy, config limit, and the snippet commands
475
476**Files:**
477- Create: `internal/policy/snippets.go`
478- Modify: `internal/config/config.go:175-193` (the `Limits` struct) and the defaults near line 217
479- Create: `internal/control/snippet.go`
480- Modify: `cmd/gitbay/main.go:74-77` (after the `wiki` group)
481- Modify: `e2e/readonly_test.go:72-73` (fixtures) and `:146-158` (`readArgs`)
482- Test: `e2e/snippet_test.go`
483
484**Interfaces:**
485- Consumes the store API from Task 1.
486- Produces:
487
488```go
489// internal/policy/snippets.go
490func CanReadSnippet(user store.User, sn store.Snippet) bool
491func CanWriteSnippet(user store.User, sn store.Snippet) bool
492
493// internal/config/config.go
494Limits.MaxSnippetBytes int64 `toml:"max_snippet_bytes"` // default 1 << 20
495
496// internal/control/snippet.go
497type SnippetFileOut struct {
498 Name string `json:"name"`
499 Size int64 `json:"size"`
500 Content string `json:"content,omitempty"`
501}
502type SnippetOut struct {
503 ID string `json:"id"`
504 URL string `json:"url"`
505 Owner string `json:"owner"`
506 Description string `json:"description"`
507 Visibility string `json:"visibility"`
508 CreatedAt string `json:"created_at"`
509 UpdatedAt string `json:"updated_at"`
510 Files []SnippetFileOut `json:"files"`
511}
512```
513
514Commands registered: `snippet create`, `snippet show`, `snippet list`, `snippet edit`, `snippet delete`, `snippet file set`, `snippet file get`, `snippet file remove`.
515
516- [ ] **Step 1: Write the failing e2e test**
517
518Create `e2e/snippet_test.go`:
519
520```go
521package e2e
522
523import (
524 "encoding/json"
525 "regexp"
526 "strings"
527 "testing"
528)
529
530// Snippets over SSH: create from stdin, read back, list by visibility,
531// edit files and metadata, and the not-found rule for private ones.
532func TestSnippets(t *testing.T) {
533 inst := startInstance(t)
534 aliceKey := inst.newKey(t, "alice")
535 bobKey := inst.newKey(t, "bob")
536 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
537 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub", "--email", "bob@example.test", "--verified")
538 must := func(key, stdin string, args ...string) string {
539 t.Helper()
540 out, errOut, code := inst.ssh(t, key, stdin, args...)
541 if code != 0 {
542 t.Fatalf("%v: exit %d %s", args, code, errOut)
543 }
544 return out
545 }
546 fails := func(key, stdin string, want int, args ...string) string {
547 t.Helper()
548 _, errOut, code := inst.ssh(t, key, stdin, args...)
549 if code != want {
550 t.Fatalf("%v: exit %d, want %d: %s", args, code, want, errOut)
551 }
552 return errOut
553 }
554 idOf := func(out string) string {
555 t.Helper()
556 var env struct {
557 Data struct {
558 ID string `json:"id"`
559 URL string `json:"url"`
560 } `json:"data"`
561 }
562 if err := json.Unmarshal([]byte(out), &env); err != nil || !regexp.MustCompile(`^[0-9a-f]{12}$`).MatchString(env.Data.ID) {
563 t.Fatalf("create output: %s", out)
564 }
565 if !strings.HasSuffix(env.Data.URL, "/alice/-/snippets/"+env.Data.ID) {
566 t.Fatalf("url: %s", env.Data.URL)
567 }
568 return env.Data.ID
569 }
570
571 // Create with the default visibility, read back byte for byte.
572 body := "line one\nline two\n"
573 unlisted := idOf(must(aliceKey, body, "snippet", "create", "build.log", "--description", "'a log'", "--json"))
574 if got := must(aliceKey, "", "snippet", "file", "get", unlisted, "build.log"); got != body {
575 t.Fatalf("file get: %q", got)
576 }
577 out := must(aliceKey, "", "snippet", "show", unlisted, "--json")
578 if !strings.Contains(out, `"visibility":"unlisted"`) || !strings.Contains(out, `"content":"line one\nline two\n"`) {
579 t.Fatalf("show: %s", out)
580 }
581 public := idOf(must(aliceKey, "pub\n", "snippet", "create", "a.txt", "--visibility", "public", "--json"))
582 private := idOf(must(aliceKey, "sec\n", "snippet", "create", "b.txt", "--visibility", "private", "--json"))
583
584 // Refusals on create: empty, not text, over the limit, bad name.
585 fails(aliceKey, "", 2, "snippet", "create", "x.txt")
586 fails(aliceKey, "\xff\xfe\n", 2, "snippet", "create", "x.bin")
587 fails(aliceKey, strings.Repeat("x", 1<<20+1), 2, "snippet", "create", "big.txt")
588 fails(aliceKey, "x\n", 2, "snippet", "create", "../x")
589 fails(aliceKey, "x\n", 2, "snippet", "create", "x.txt", "--visibility", "secret")
590
591 // Visibility from the other side. Private is not-found, never denied.
592 fails(bobKey, "", 3, "snippet", "show", private)
593 fails(bobKey, "", 3, "snippet", "file", "get", private, "b.txt")
594 must(bobKey, "", "snippet", "show", unlisted)
595 out = must(bobKey, "", "snippet", "list", "alice", "--json")
596 if !strings.Contains(out, public) || strings.Contains(out, unlisted) || strings.Contains(out, private) {
597 t.Fatalf("bob's view of alice's list: %s", out)
598 }
599 out = must(aliceKey, "", "snippet", "list", "--json")
600 for _, id := range []string{public, unlisted, private} {
601 if !strings.Contains(out, id) {
602 t.Fatalf("alice's own list lacks %s: %s", id, out)
603 }
604 }
605 fails(bobKey, "", 3, "snippet", "list", "nobody")
606
607 // Paging: two pages of one, the second reached by cursor.
608 out = must(aliceKey, "", "snippet", "list", "--limit", "1", "--json")
609 var page struct {
610 Data struct {
611 Items []struct{ ID string `json:"id"` } `json:"items"`
612 Next string `json:"next"`
613 } `json:"data"`
614 }
615 json.Unmarshal([]byte(out), &page)
616 if len(page.Data.Items) != 1 || page.Data.Items[0].ID != private || page.Data.Next == "" {
617 t.Fatalf("first page: %s", out)
618 }
619 out = must(aliceKey, "", "snippet", "list", "--limit", "1", "--cursor", page.Data.Next, "--json")
620 if !strings.Contains(out, public) {
621 t.Fatalf("second page: %s", out)
622 }
623
624 // Files: set adds, set replaces, remove drops, the last one stays.
625 must(aliceKey, "notes\n", "snippet", "file", "set", unlisted, "notes.txt")
626 must(aliceKey, "changed\n", "snippet", "file", "set", unlisted, "build.log")
627 if got := must(aliceKey, "", "snippet", "file", "get", unlisted, "build.log"); got != "changed\n" {
628 t.Fatalf("after replace: %q", got)
629 }
630 must(aliceKey, "", "snippet", "file", "remove", unlisted, "notes.txt")
631 fails(aliceKey, "", 3, "snippet", "file", "remove", unlisted, "notes.txt")
632 if msg := fails(aliceKey, "", 2, "snippet", "file", "remove", unlisted, "build.log"); !strings.Contains(msg, "at least one file") {
633 t.Fatalf("last file removal: %s", msg)
634 }
635
636 // Only the owner writes: denied on a readable one, not-found on a private one.
637 fails(bobKey, "x\n", 4, "snippet", "file", "set", unlisted, "x.txt")
638 fails(bobKey, "", 4, "snippet", "edit", unlisted, "--description", "mine")
639 fails(bobKey, "", 4, "snippet", "delete", unlisted)
640 fails(bobKey, "", 3, "snippet", "delete", private)
641
642 // Edit moves visibility and the listing follows.
643 fails(aliceKey, "", 2, "snippet", "edit", unlisted)
644 must(aliceKey, "", "snippet", "edit", unlisted, "--visibility", "public", "--description", "shared")
645 out = must(bobKey, "", "snippet", "list", "alice", "--json")
646 if !strings.Contains(out, unlisted) || !strings.Contains(out, `"description":"shared"`) {
647 t.Fatalf("list after edit: %s", out)
648 }
649
650 // Delete, then gone; deleting the user takes the rest.
651 must(aliceKey, "", "snippet", "delete", unlisted)
652 fails(aliceKey, "", 3, "snippet", "show", unlisted)
653 inst.admin(t, "admin", "user", "delete", "alice", "--yes")
654 fails(bobKey, "", 3, "snippet", "show", public)
655}
656```
657
658- [ ] **Step 2: Run it to see it fail**
659
660Run: `go test ./e2e -run 'TestSnippets$'`
661Expected: FAIL at the first `must`: `unknown command "snippet"` (or similar) with exit 2.
662
663- [ ] **Step 3: Policy**
664
665Create `internal/policy/snippets.go`:
666
667```go
668package policy
669
670import "gitbay.org/gitbay/internal/store"
671
672// CanReadSnippet: anyone for public and unlisted, the owner and admins
673// for private. Anonymous readers have user.ID 0.
674func CanReadSnippet(user store.User, sn store.Snippet) bool {
675 if sn.Visibility != "private" {
676 return true
677 }
678 return user.ID != 0 && (user.ID == sn.OwnerID || user.IsAdmin)
679}
680
681// CanWriteSnippet: the owner and admins.
682func CanWriteSnippet(user store.User, sn store.Snippet) bool {
683 return user.ID != 0 && (user.ID == sn.OwnerID || user.IsAdmin)
684}
685```
686
687- [ ] **Step 4: Config limit**
688
689In `internal/config/config.go`, add to `Limits` after `MaxAssetBytes`:
690
691```go
692 MaxSnippetBytes int64 `toml:"max_snippet_bytes"` // per snippet file
693```
694
695and in the defaults block that sets `MaxAssetBytes: 512 << 20`, add:
696
697```go
698 MaxSnippetBytes: 1 << 20,
699```
700
701- [ ] **Step 5: The commands**
702
703Create `internal/control/snippet.go`:
704
705```go
706package control
707
708import (
709 "crypto/rand"
710 "encoding/hex"
711 "errors"
712 "fmt"
713 "io"
714 "strconv"
715 "unicode/utf8"
716
717 "gitbay.org/gitbay/internal/policy"
718 "gitbay.org/gitbay/internal/protocol"
719 "gitbay.org/gitbay/internal/store"
720)
721
722// A snippet keeps at most this many files; a paste is not a repository.
723const maxSnippetFiles = 64
724
725func init() {
726 register(Command{Path: []string{"snippet", "create"},
727 Summary: "create a snippet from one file on stdin",
728 Usage: "snippet create <filename> [--description <d>] [--visibility public|unlisted|private] < file",
729 ReadsStdin: true, Run: runSnippetCreate})
730 register(Command{Path: []string{"snippet", "show"},
731 Summary: "show a snippet's metadata and files",
732 Usage: "snippet show <id>", ReadOnly: true, Run: runSnippetShow})
733 register(Command{Path: []string{"snippet", "list"},
734 Summary: "list your snippets, or an owner's public ones",
735 Usage: "snippet list [<owner>] [--limit n] [--cursor c]", ReadOnly: true, Run: runSnippetList})
736 register(Command{Path: []string{"snippet", "edit"},
737 Summary: "change a snippet's description or visibility",
738 Usage: "snippet edit <id> [--description <d>] [--visibility public|unlisted|private]", Run: runSnippetEdit})
739 register(Command{Path: []string{"snippet", "delete"},
740 Summary: "delete a snippet and its files",
741 Usage: "snippet delete <id>", Run: runSnippetDelete})
742 register(Command{Path: []string{"snippet", "file", "set"},
743 Summary: "add a file to a snippet, or replace one, from stdin",
744 Usage: "snippet file set <id> <filename> < file",
745 ReadsStdin: true, Run: runSnippetFileSet})
746 register(Command{Path: []string{"snippet", "file", "get"},
747 Summary: "write a snippet file to stdout",
748 Usage: "snippet file get <id> <filename> > file", ReadOnly: true, Run: runSnippetFileGet})
749 register(Command{Path: []string{"snippet", "file", "remove"},
750 Summary: "remove a file from a snippet",
751 Usage: "snippet file remove <id> <filename>", Run: runSnippetFileRemove})
752}
753
754type SnippetFileOut struct {
755 Name string `json:"name"`
756 Size int64 `json:"size"`
757 Content string `json:"content,omitempty"`
758}
759
760type SnippetOut struct {
761 ID string `json:"id"`
762 URL string `json:"url"`
763 Owner string `json:"owner"`
764 Description string `json:"description"`
765 Visibility string `json:"visibility"`
766 CreatedAt string `json:"created_at"`
767 UpdatedAt string `json:"updated_at"`
768 Files []SnippetFileOut `json:"files"`
769}
770
771func snippetURL(c *Ctx, sn store.Snippet) string {
772 return c.Cfg.Server.SiteURL + "/" + sn.OwnerName + "/-/snippets/" + sn.PublicID
773}
774
775func snippetOut(c *Ctx, sn store.Snippet) SnippetOut {
776 o := SnippetOut{ID: sn.PublicID, URL: snippetURL(c, sn), Owner: sn.OwnerName,
777 Description: sn.Description, Visibility: sn.Visibility,
778 CreatedAt: sn.CreatedAt, UpdatedAt: sn.UpdatedAt, Files: []SnippetFileOut{}}
779 for _, f := range sn.Files {
780 o.Files = append(o.Files, SnippetFileOut{Name: f.Name, Size: f.Size, Content: string(f.Content)})
781 }
782 return o
783}
784
785func validSnippetVisibility(v string) bool {
786 return v == "public" || v == "unlisted" || v == "private"
787}
788
789// snippetRef loads a snippet the caller may read; with write, one they
790// may change. Unreadable and missing are the same not-found, so a
791// private id cannot be confirmed by probing.
792func snippetRef(c *Ctx, id string, write bool) (store.Snippet, int) {
793 sn, err := c.Store.SnippetByPublicID(id)
794 if err != nil && !errors.Is(err, store.ErrNotFound) {
795 return sn, c.fail(protocol.ExitFailure, "%v", err)
796 }
797 if err != nil || !policy.CanReadSnippet(c.User, sn) {
798 return sn, c.fail(protocol.ExitNotFound, "no snippet %q", id)
799 }
800 if write && !policy.CanWriteSnippet(c.User, sn) {
801 return sn, c.fail(protocol.ExitDenied, "snippet %s belongs to %s", id, sn.OwnerName)
802 }
803 return sn, -1
804}
805
806// readSnippetBody reads one file from stdin under the limit, and insists
807// on text: the page highlights it and the raw route serves text/plain.
808func readSnippetBody(c *Ctx) ([]byte, int) {
809 limit := c.Cfg.Limits.MaxSnippetBytes
810 data, err := io.ReadAll(io.LimitReader(c.Stdin, limit+1))
811 if err != nil {
812 return nil, c.fail(protocol.ExitFailure, "reading stdin: %v", err)
813 }
814 if int64(len(data)) > limit {
815 return nil, c.fail(protocol.ExitUsage, "file exceeds max_snippet_bytes (%d)", limit)
816 }
817 if len(data) == 0 {
818 return nil, c.fail(protocol.ExitUsage, "empty file: pipe it on stdin")
819 }
820 if !utf8.Valid(data) {
821 return nil, c.fail(protocol.ExitUsage, "snippets hold text: the file is not valid UTF-8")
822 }
823 return data, -1
824}
825
826func checkSnippetFileName(c *Ctx, name string) int {
827 if !assetNamePat.MatchString(name) {
828 return c.fail(protocol.ExitUsage, "invalid file name %q: letters, digits, '._+-'; must not start with '.'", name)
829 }
830 return -1
831}
832
833func newSnippetID() string {
834 buf := make([]byte, 6)
835 rand.Read(buf)
836 return hex.EncodeToString(buf)
837}
838
839func runSnippetCreate(c *Ctx, args []string) int {
840 const usage = "usage: snippet create <filename> [--description <d>] [--visibility public|unlisted|private] < file"
841 f, err := parseFlags(args, flagSpec{Values: []string{"--description", "--visibility"}, MaxPos: 1, Usage: usage})
842 if err != nil {
843 return c.fail(protocol.ExitUsage, "%v", err)
844 }
845 name := f.pos(0)
846 if name == "" {
847 return c.fail(protocol.ExitUsage, usage)
848 }
849 if code := checkSnippetFileName(c, name); code >= 0 {
850 return code
851 }
852 visibility := f.Value("--visibility")
853 if visibility == "" {
854 visibility = "unlisted"
855 }
856 if !validSnippetVisibility(visibility) {
857 return c.fail(protocol.ExitUsage, "visibility is public, unlisted or private")
858 }
859 data, code := readSnippetBody(c)
860 if code >= 0 {
861 return code
862 }
863 var pid string
864 for try := 0; ; try++ {
865 pid = newSnippetID()
866 _, err = c.Store.CreateSnippet(c.User.ID, pid, f.Value("--description"), visibility, name, data)
867 if !errors.Is(err, store.ErrExists) || try == 4 {
868 break
869 }
870 }
871 if err != nil {
872 return c.failErr(err)
873 }
874 sn, err := c.Store.SnippetByPublicID(pid)
875 if err != nil {
876 return c.fail(protocol.ExitFailure, "%v", err)
877 }
878 return c.emit(snippetOut(c, sn), func(w io.Writer) {
879 fmt.Fprintf(w, "created snippet %s\n%s\n", sn.PublicID, snippetURL(c, sn))
880 })
881}
882
883func runSnippetShow(c *Ctx, args []string) int {
884 if len(args) != 1 {
885 return c.fail(protocol.ExitUsage, "usage: snippet show <id>")
886 }
887 sn, code := snippetRef(c, args[0], false)
888 if code >= 0 {
889 return code
890 }
891 files, err := c.Store.SnippetFiles(sn.ID)
892 if err != nil {
893 return c.fail(protocol.ExitFailure, "%v", err)
894 }
895 sn.Files = files
896 return c.emit(snippetOut(c, sn), func(w io.Writer) {
897 fmt.Fprintf(w, "snippet %s by %s (%s)\n", sn.PublicID, sn.OwnerName, sn.Visibility)
898 if sn.Description != "" {
899 fmt.Fprintf(w, "%s\n", sn.Description)
900 }
901 fmt.Fprintf(w, "%s\nupdated %s\n", snippetURL(c, sn), sn.UpdatedAt)
902 for _, f := range files {
903 fmt.Fprintf(w, " %s\t%d bytes\n", f.Name, f.Size)
904 }
905 })
906}
907
908func runSnippetList(c *Ctx, args []string) int {
909 rest, p, code := parsePageFlags(c, args, "snippet", true)
910 if code >= 0 {
911 return code
912 }
913 if len(rest) > 1 {
914 return c.fail(protocol.ExitUsage, "usage: snippet list [<owner>] [--limit n] [--cursor c]")
915 }
916 owner := c.User
917 if len(rest) == 1 {
918 u, err := c.Store.UserByUsername(rest[0])
919 if errors.Is(err, store.ErrNotFound) {
920 return c.fail(protocol.ExitNotFound, "no user %q", rest[0])
921 }
922 if err != nil {
923 return c.fail(protocol.ExitFailure, "%v", err)
924 }
925 owner = u
926 }
927 all := owner.ID == c.User.ID || c.User.IsAdmin
928 rows, err := c.Store.ListSnippets(owner.ID, all, p.queryLimit(), p.keyInt())
929 if err != nil {
930 return c.fail(protocol.ExitFailure, "%v", err)
931 }
932 rows, next := trimPage(p, rows, "snippet", func(sn store.Snippet) string { return strconv.FormatInt(sn.ID, 10) })
933 items := make([]SnippetOut, 0, len(rows))
934 for _, sn := range rows {
935 items = append(items, snippetOut(c, sn))
936 }
937 return c.emitPage(p, items, next, func(w io.Writer) {
938 for _, sn := range rows {
939 names := ""
940 for i, f := range sn.Files {
941 if i > 0 {
942 names += ", "
943 }
944 names += f.Name
945 }
946 fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", sn.PublicID, sn.Visibility, names, sn.Description)
947 }
948 })
949}
950
951func runSnippetEdit(c *Ctx, args []string) int {
952 const usage = "usage: snippet edit <id> [--description <d>] [--visibility public|unlisted|private]"
953 f, err := parseFlags(args, flagSpec{Values: []string{"--description", "--visibility"}, MaxPos: 1, Usage: usage})
954 if err != nil {
955 return c.fail(protocol.ExitUsage, "%v", err)
956 }
957 if f.pos(0) == "" || (!f.Has("--description") && !f.Has("--visibility")) {
958 return c.fail(protocol.ExitUsage, usage)
959 }
960 sn, code := snippetRef(c, f.pos(0), true)
961 if code >= 0 {
962 return code
963 }
964 description, visibility := sn.Description, sn.Visibility
965 if f.Has("--description") {
966 description = f.Value("--description")
967 }
968 if f.Has("--visibility") {
969 visibility = f.Value("--visibility")
970 if !validSnippetVisibility(visibility) {
971 return c.fail(protocol.ExitUsage, "visibility is public, unlisted or private")
972 }
973 }
974 if err := c.Store.UpdateSnippet(sn.ID, description, visibility); err != nil {
975 return c.failErr(err)
976 }
977 sn, err = c.Store.SnippetByPublicID(sn.PublicID)
978 if err != nil {
979 return c.fail(protocol.ExitFailure, "%v", err)
980 }
981 return c.emit(snippetOut(c, sn), func(w io.Writer) {
982 fmt.Fprintf(w, "updated snippet %s (%s)\n", sn.PublicID, sn.Visibility)
983 })
984}
985
986func runSnippetDelete(c *Ctx, args []string) int {
987 if len(args) != 1 {
988 return c.fail(protocol.ExitUsage, "usage: snippet delete <id>")
989 }
990 sn, code := snippetRef(c, args[0], true)
991 if code >= 0 {
992 return code
993 }
994 if err := c.Store.DeleteSnippet(sn.ID); err != nil {
995 return c.failErr(err)
996 }
997 return c.emit(map[string]string{"id": sn.PublicID}, func(w io.Writer) {
998 fmt.Fprintf(w, "deleted snippet %s\n", sn.PublicID)
999 })
1000}
1001
1002func runSnippetFileSet(c *Ctx, args []string) int {
1003 if len(args) != 2 {
1004 return c.fail(protocol.ExitUsage, "usage: snippet file set <id> <filename> < file")
1005 }
1006 sn, code := snippetRef(c, args[0], true)
1007 if code >= 0 {
1008 return code
1009 }
1010 name := args[1]
1011 if code := checkSnippetFileName(c, name); code >= 0 {
1012 return code
1013 }
1014 exists := false
1015 for _, f := range sn.Files {
1016 exists = exists || f.Name == name
1017 }
1018 if !exists && len(sn.Files) >= maxSnippetFiles {
1019 return c.fail(protocol.ExitUsage, "a snippet holds at most %d files", maxSnippetFiles)
1020 }
1021 data, code := readSnippetBody(c)
1022 if code >= 0 {
1023 return code
1024 }
1025 if err := c.Store.SetSnippetFile(sn.ID, name, data); err != nil {
1026 return c.failErr(err)
1027 }
1028 return c.emit(SnippetFileOut{Name: name, Size: int64(len(data))}, func(w io.Writer) {
1029 fmt.Fprintf(w, "set %s (%d bytes) on snippet %s\n", name, len(data), sn.PublicID)
1030 })
1031}
1032
1033func runSnippetFileGet(c *Ctx, args []string) int {
1034 if len(args) != 2 {
1035 return c.fail(protocol.ExitUsage, "usage: snippet file get <id> <filename> > file")
1036 }
1037 sn, code := snippetRef(c, args[0], false)
1038 if code >= 0 {
1039 return code
1040 }
1041 f, err := c.Store.SnippetFile(sn.ID, args[1])
1042 if errors.Is(err, store.ErrNotFound) {
1043 return c.fail(protocol.ExitNotFound, "no file %q in snippet %s", args[1], sn.PublicID)
1044 }
1045 if err != nil {
1046 return c.fail(protocol.ExitFailure, "%v", err)
1047 }
1048 if c.JSON {
1049 return c.emit(SnippetFileOut{Name: f.Name, Size: f.Size, Content: string(f.Content)}, nil)
1050 }
1051 if _, err := c.Stdout.Write(f.Content); err != nil {
1052 return protocol.ExitFailure
1053 }
1054 return protocol.ExitOK
1055}
1056
1057func runSnippetFileRemove(c *Ctx, args []string) int {
1058 if len(args) != 2 {
1059 return c.fail(protocol.ExitUsage, "usage: snippet file remove <id> <filename>")
1060 }
1061 sn, code := snippetRef(c, args[0], true)
1062 if code >= 0 {
1063 return code
1064 }
1065 if len(sn.Files) == 1 && sn.Files[0].Name == args[1] {
1066 return c.fail(protocol.ExitUsage, "a snippet keeps at least one file; delete the snippet instead")
1067 }
1068 err := c.Store.RemoveSnippetFile(sn.ID, args[1])
1069 if errors.Is(err, store.ErrNotFound) {
1070 return c.fail(protocol.ExitNotFound, "no file %q in snippet %s", args[1], sn.PublicID)
1071 }
1072 if err != nil {
1073 return c.failErr(err)
1074 }
1075 return c.emit(map[string]string{"id": sn.PublicID, "name": args[1]}, func(w io.Writer) {
1076 fmt.Fprintf(w, "removed %s from snippet %s\n", args[1], sn.PublicID)
1077 })
1078}
1079```
1080
1081One note for the implementer: `c.emit(..., nil)` in `runSnippetFileGet` is only reached under `c.JSON`, where `emit` never calls the plain function; keep the `if c.JSON` guard.
1082
1083- [ ] **Step 6: CLI rows**
1084
1085In `cmd/gitbay/main.go`, directly after the `group("wiki", ...)` entry (around line 77), add:
1086
1087```go
1088 group("snippet", "shared text files, outside any repository",
1089 pass("create", "create from one file on stdin: <filename> [--description d] [--visibility public|unlisted|private] < file",
1090 passOpts{server: []string{"snippet", "create"}, alwaysStdin: true, stdinWhat: "the file's text"}),
1091 pass("show", "metadata and files: <id>", passOpts{server: []string{"snippet", "show"}}),
1092 pass("list", "your snippets, or an owner's public ones: [<owner>] [--limit n] [--cursor c]",
1093 passOpts{server: []string{"snippet", "list"}}),
1094 pass("edit", "change description or visibility: <id> [--description d] [--visibility v]",
1095 passOpts{server: []string{"snippet", "edit"}}),
1096 pass("delete", "delete a snippet: <id>", passOpts{server: []string{"snippet", "delete"}}),
1097 group("file", "the files in a snippet",
1098 pass("set", "add or replace a file from stdin: <id> <filename> < file",
1099 passOpts{server: []string{"snippet", "file", "set"}, alwaysStdin: true, stdinWhat: "the file's text"}),
1100 pass("get", "print a file: <id> <filename> > file", passOpts{server: []string{"snippet", "file", "get"}}),
1101 pass("remove", "remove a file: <id> <filename>", passOpts{server: []string{"snippet", "file", "remove"}}),
1102 ),
1103 ),
1104```
1105
1106- [ ] **Step 7: Read-only coverage entries**
1107
1108In `e2e/readonly_test.go`, after the line `must("data\n", "release", "asset", "add", "alice/app", "v1", "a.txt")` add:
1109
1110```go
1111 snippetOut := must("hello\n", "snippet", "create", "a.txt", "--json")
1112 snippetID := regexp.MustCompile(`"id":"([0-9a-f]{12})"`).FindStringSubmatch(snippetOut)[1]
1113```
1114
1115Add `"regexp"` to the file's imports if it is not there. In the `readArgs` map add, beside the `release` rows:
1116
1117```go
1118 "snippet show": {snippetID},
1119 "snippet list": {},
1120 "snippet file get": {snippetID, "a.txt"},
1121```
1122
1123- [ ] **Step 8: Build, vet, run the tests**
1124
1125Run: `go build ./... && go vet ./... && go test ./internal/control ./internal/policy ./internal/config && go test ./e2e -run 'TestSnippets$|TestCLI$|TestReadOnlyCommandsWriteNothing$'`
1126Expected: all PASS. `TestCLI` proves every `snippet` command has a CLI row with a `stdinWhat`; `TestReadOnlyCommandsWriteNothing` proves the three reads write nothing.
1127
1128If `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`.
1129
1130- [ ] **Step 9: Commit**
1131
1132```bash
1133git 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
1134git commit -m "control: snippet commands
1135
1136create, show, list, edit, delete, and file set|get|remove. Content is
1137UTF-8 under limits.max_snippet_bytes per file, 64 files per snippet.
1138Private snippets are not-found to everyone but the owner and admins.
1139
1140Ref #195"
1141```
1142
1143---
1144
1145### Task 3: Web read pages and the owner-page link
1146
1147**Files:**
1148- Create: `internal/httpd/snippets.go`
1149- Create: `internal/web/templates/snippets.html`
1150- Create: `internal/web/templates/snippet.html`
1151- Modify: `internal/httpd/routes.go:74-75` (beside the `/{owner}/-/labels` routes)
1152- Modify: `internal/control/profile.go:165-185` (`ProfileOut`) and the `profile show` body near line 296
1153- Modify: `internal/httpd/web.go:436-466` (the `owner.html` page struct and its literal)
1154- Modify: `internal/web/templates/owner.html:21-25`
1155- Test: `e2e/snippetweb_test.go`
1156
1157**Interfaces:**
1158- 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`.
1159- 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.
1160
1161- [ ] **Step 1: Write the failing e2e test (read half)**
1162
1163Create `e2e/snippetweb_test.go`:
1164
1165```go
1166package e2e
1167
1168import (
1169 "encoding/json"
1170 "net/http"
1171 "net/url"
1172 "strings"
1173 "testing"
1174)
1175
1176func snippetIDFrom(t *testing.T, out string) string {
1177 t.Helper()
1178 var env struct {
1179 Data struct {
1180 ID string `json:"id"`
1181 } `json:"data"`
1182 }
1183 if err := json.Unmarshal([]byte(out), &env); err != nil || env.Data.ID == "" {
1184 t.Fatalf("snippet create: %s", out)
1185 }
1186 return env.Data.ID
1187}
1188
1189// Snippet pages: the owner's list, one snippet with highlighted files, the
1190// raw route, the owner-page link, and 404 for what the viewer may not see.
1191func TestSnippetsWeb(t *testing.T) {
1192 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
1193 aliceKey := inst.newKey(t, "alice")
1194 bobKey := inst.newKey(t, "bob")
1195 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
1196 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub", "--email", "bob@example.test", "--verified")
1197 must := func(key, stdin string, args ...string) string {
1198 t.Helper()
1199 out, errOut, code := inst.ssh(t, key, stdin, args...)
1200 if code != 0 {
1201 t.Fatalf("%v: exit %d %s", args, code, errOut)
1202 }
1203 return out
1204 }
1205 public := snippetIDFrom(t, must(aliceKey, "package main\n", "snippet", "create", "main.go", "--visibility", "public", "--description", "'hello world'", "--json"))
1206 unlisted := snippetIDFrom(t, must(aliceKey, "quiet\n", "snippet", "create", "q.txt", "--json"))
1207 private := snippetIDFrom(t, must(aliceKey, "secret\n", "snippet", "create", "s.txt", "--visibility", "private", "--json"))
1208
1209 // Anonymous: the public list, the unlisted page by URL, 404 for private.
1210 status, body := inst.get(t, "/alice/-/snippets")
1211 if status != 200 || !strings.Contains(body, public) || strings.Contains(body, unlisted) || strings.Contains(body, private) {
1212 t.Fatalf("anonymous list: %d\n%s", status, body)
1213 }
1214 status, body = inst.get(t, "/alice/-/snippets/"+public)
1215 if status != 200 || !strings.Contains(body, "hello world") || !strings.Contains(body, `class="chroma"`) || !strings.Contains(body, "/raw/main.go") {
1216 t.Fatalf("public page: %d\n%s", status, body)
1217 }
1218 if status, _ := inst.get(t, "/alice/-/snippets/"+unlisted); status != 200 {
1219 t.Fatalf("unlisted page: %d", status)
1220 }
1221 if status, _ := inst.get(t, "/alice/-/snippets/"+private); status != 404 {
1222 t.Fatalf("private page for anonymous: %d", status)
1223 }
1224 if status, _ := inst.get(t, "/bob/-/snippets/"+public); status != 404 {
1225 t.Fatalf("id under the wrong owner: %d", status)
1226 }
1227 if status, _ := inst.get(t, "/nobody/-/snippets"); status != 404 {
1228 t.Fatalf("list for a missing owner: %d", status)
1229 }
1230
1231 // Raw is text/plain with nosniff, whatever the extension.
1232 resp, err := http.Get(inst.base() + "/alice/-/snippets/" + public + "/raw/main.go")
1233 if err != nil {
1234 t.Fatal(err)
1235 }
1236 resp.Body.Close()
1237 if resp.StatusCode != 200 || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/plain") || resp.Header.Get("X-Content-Type-Options") != "nosniff" {
1238 t.Fatalf("raw headers: %d %v", resp.StatusCode, resp.Header)
1239 }
1240 if status, _ := inst.get(t, "/alice/-/snippets/" + public + "/raw/other.go"); status != 404 {
1241 t.Fatalf("raw for a missing file: %d", status)
1242 }
1243
1244 // The owner sees everything with visibility marks; the owner page links.
1245 alice := inst.login(t, aliceKey)
1246 status, body = browserGet(t, alice, inst.base()+"/alice/-/snippets")
1247 if status != 200 || !strings.Contains(body, private) || !strings.Contains(body, ">private<") {
1248 t.Fatalf("owner list: %d\n%s", status, body)
1249 }
1250 if status, body := browserGet(t, alice, inst.base()+"/alice/-/snippets/"+private); status != 200 || !strings.Contains(body, "secret") {
1251 t.Fatalf("owner's private page: %d", status)
1252 }
1253 if status, body := inst.get(t, "/alice"); status != 200 || !strings.Contains(body, `href="/alice/-/snippets"`) {
1254 t.Fatalf("owner page lacks the snippets link: %d", status)
1255 }
1256 // bob has no public snippets and is not the viewer: no link.
1257 if status, body := inst.get(t, "/bob"); status != 200 || strings.Contains(body, `href="/bob/-/snippets"`) {
1258 t.Fatalf("bob's page shows a snippets link with nothing to list: %d", status)
1259 }
1260
1261 _ = url.Values{}
1262 _ = bobKey
1263}
1264```
1265
1266The last two lines keep the imports and `bobKey` used until Task 4 extends the test; Task 4 removes them.
1267
1268- [ ] **Step 2: Run it to see it fail**
1269
1270Run: `go test ./e2e -run 'TestSnippetsWeb$'`
1271Expected: FAIL at "anonymous list", status 404.
1272
1273- [ ] **Step 3: Profile count**
1274
1275In `internal/control/profile.go`, add to `ProfileOut` after `Repos`:
1276
1277```go
1278 // Snippets counts the owner's snippets the caller may list: public
1279 // ones, or all of them for the owner and admins. Orgs own none.
1280 Snippets int `json:"snippets"`
1281```
1282
1283In the `profile show` body, after the loop that fills `d.Repos` and before the activity counts, add:
1284
1285```go
1286 if kind == "user" {
1287 all := id == c.User.ID || c.User.IsAdmin
1288 if d.Snippets, err = c.Store.CountSnippets(id, all); err != nil {
1289 return c.fail(protocol.ExitFailure, "%v", err)
1290 }
1291 }
1292```
1293
1294(`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.)
1295
1296- [ ] **Step 4: Routes**
1297
1298In `internal/httpd/routes.go`, after the `/{owner}/-/milestones` route add:
1299
1300```go
1301 Route{Method: "GET", Pattern: "/{owner}/-/snippets", Handler: s.snippetsPage},
1302 Route{Method: "GET", Pattern: "/{owner}/-/snippets/{id}", Handler: s.snippetPage},
1303 Route{Method: "GET", Pattern: "/{owner}/-/snippets/{id}/raw/{name}", Handler: s.snippetRaw},
1304```
1305
1306These are read routes, registered in every web mode; the literal `-` and `snippets` segments keep them from overlapping any `/{owner}/{repo}/...` pattern.
1307
1308- [ ] **Step 5: Handlers**
1309
1310Create `internal/httpd/snippets.go`:
1311
1312```go
1313package httpd
1314
1315import (
1316 "bytes"
1317 "html/template"
1318 "net/http"
1319
1320 "gitbay.org/gitbay/internal/policy"
1321 "gitbay.org/gitbay/internal/store"
1322)
1323
1324// snippetScope resolves the owner and id in the URL for the viewer. A
1325// missing owner, an id under another owner, and a private snippet the
1326// viewer may not read are all the same 404.
1327func (s *Server) snippetScope(w http.ResponseWriter, r *http.Request) (store.Snippet, store.User, bool) {
1328 viewer := s.viewer(r)
1329 sn, err := s.st.SnippetByPublicID(r.PathValue("id"))
1330 if err != nil || sn.OwnerName != r.PathValue("owner") || !policy.CanReadSnippet(viewer, sn) {
1331 s.notFound(w, r)
1332 return sn, viewer, false
1333 }
1334 return sn, viewer, true
1335}
1336
1337type snippetRow struct {
1338 store.Snippet
1339 Names string
1340}
1341
1342func (s *Server) snippetsPage(w http.ResponseWriter, r *http.Request) {
1343 viewer := s.viewer(r)
1344 owner, err := s.st.UserByUsername(r.PathValue("owner"))
1345 if err != nil {
1346 s.notFound(w, r)
1347 return
1348 }
1349 self := viewer.ID != 0 && viewer.ID == owner.ID
1350 all := self || viewer.IsAdmin
1351 list, err := s.st.ListSnippets(owner.ID, all, 0, 0)
1352 if err != nil {
1353 http.Error(w, "internal error", http.StatusInternalServerError)
1354 return
1355 }
1356 rows := make([]snippetRow, 0, len(list))
1357 for _, sn := range list {
1358 var names bytes.Buffer
1359 for i, f := range sn.Files {
1360 if i > 0 {
1361 names.WriteString(", ")
1362 }
1363 names.WriteString(f.Name)
1364 }
1365 rows = append(rows, snippetRow{sn, names.String()})
1366 }
1367 s.render(w, "snippets.html", struct {
1368 basePage
1369 Owner string
1370 Self bool
1371 All bool
1372 Snippets []snippetRow
1373 Notice string
1374 }{s.baseFor(viewer), owner.Username, self, all, rows, s.takeFlash(w, r)})
1375}
1376
1377type snippetFileView struct {
1378 Name string
1379 Size int64
1380 Lines int
1381 Content string
1382 HTML template.HTML
1383}
1384
1385func (s *Server) snippetPage(w http.ResponseWriter, r *http.Request) {
1386 sn, viewer, ok := s.snippetScope(w, r)
1387 if !ok {
1388 return
1389 }
1390 files, err := s.st.SnippetFiles(sn.ID)
1391 if err != nil {
1392 http.Error(w, "internal error", http.StatusInternalServerError)
1393 return
1394 }
1395 views := make([]snippetFileView, 0, len(files))
1396 for _, f := range files {
1397 lines := bytes.Count(f.Content, []byte("\n"))
1398 if len(f.Content) > 0 && f.Content[len(f.Content)-1] != '\n' {
1399 lines++
1400 }
1401 views = append(views, snippetFileView{f.Name, f.Size, lines, string(f.Content), highlight(f.Name, f.Content)})
1402 }
1403 s.render(w, "snippet.html", struct {
1404 basePage
1405 Owner string
1406 Snippet store.Snippet
1407 Files []snippetFileView
1408 CanWrite bool
1409 Notice string
1410 }{s.baseFor(viewer), sn.OwnerName, sn, views, policy.CanWriteSnippet(viewer, sn), s.takeFlash(w, r)})
1411}
1412
1413// snippetRaw serves one file as text, inert on the forge's origin.
1414func (s *Server) snippetRaw(w http.ResponseWriter, r *http.Request) {
1415 sn, _, ok := s.snippetScope(w, r)
1416 if !ok {
1417 return
1418 }
1419 f, err := s.st.SnippetFile(sn.ID, r.PathValue("name"))
1420 if err != nil {
1421 s.notFound(w, r)
1422 return
1423 }
1424 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
1425 w.Header().Set("X-Content-Type-Options", "nosniff")
1426 w.Write(f.Content)
1427}
1428```
1429
1430`s.viewer` reads the session cookie; in `view_only` mode there is never one, so the viewer is anonymous there without a mode check.
1431
1432- [ ] **Step 6: Templates**
1433
1434Create `internal/web/templates/snippets.html`:
1435
1436```html
1437{{define "title"}}snippets · {{.Owner}}{{end}}
1438{{define "content"}}
1439<h1><a href="/{{.Owner}}">{{.Owner}}</a> snippets</h1>
1440{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
1441{{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}}
1442{{if .Snippets}}<div class="tablewrap"><table class="keys">
1443<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>
1444{{range .Snippets}}<tr>
1445 <td><a href="/{{$.Owner}}/-/snippets/{{.PublicID}}">{{if .Description}}{{.Description}}{{else}}{{.PublicID}}{{end}}</a></td>
1446 <td><span class="mono">{{.Names}}</span></td>
1447 {{if $.All}}<td><span class="chip chip-neutral">{{.Visibility}}</span></td>{{end}}
1448 <td>{{.UpdatedAt}}</td>
1449</tr>
1450{{end}}</table></div>
1451{{else}}<p class="none">No snippets yet.</p>{{end}}
1452{{end}}
1453```
1454
1455Create `internal/web/templates/snippet.html` (the write forms come in Task 4; leave the `{{if .CanWrite}}` block out for now):
1456
1457```html
1458{{define "title"}}{{if .Snippet.Description}}{{.Snippet.Description}}{{else}}{{.Snippet.PublicID}}{{end}} · {{.Owner}}{{end}}
1459{{define "content"}}
1460<h1><a href="/{{.Owner}}">{{.Owner}}</a> / <a href="/{{.Owner}}/-/snippets">snippets</a> / {{.Snippet.PublicID}}</h1>
1461{{if .Snippet.Description}}<p class="desc lede">{{.Snippet.Description}}</p>{{end}}
1462<p class="meta"><span class="chip chip-neutral">{{.Snippet.Visibility}}</span> · updated {{.Snippet.UpdatedAt}} · <code>gitbay snippet show {{.Snippet.PublicID}}</code></p>
1463{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
1464{{range .Files}}
1465<section class="snippetfile" id="file-{{.Name}}">
1466<div class="pathbar">
1467 <span class="crumbs"><strong>{{.Name}}</strong></span>
1468 <span class="spacer"></span>
1469 <span class="actions"><a href="/{{$.Owner}}/-/snippets/{{$.Snippet.PublicID}}/raw/{{.Name}}">raw</a></span>
1470</div>
1471<p class="filefacts">{{.Lines}} lines · {{.Size}} bytes</p>
1472<div class="code">{{.HTML}}</div>
1473</section>
1474{{end}}
1475{{end}}
1476```
1477
1478- [ ] **Step 7: Owner page link**
1479
1480In `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 `</ul>` that closes the repository list, add:
1481
1482```html
1483{{if or .Snippets .Self}}<p class="meta"><a href="/{{.Owner}}/-/snippets">snippets{{if .Snippets}} <span class="count">{{.Snippets}}</span>{{end}}</a></p>{{end}}
1484```
1485
1486- [ ] **Step 8: Build and run the tests**
1487
1488Run: `go build ./... && go vet ./... && go test ./internal/httpd && go test ./e2e -run 'TestSnippetsWeb$'`
1489Expected: 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).
1490
1491- [ ] **Step 9: Commit**
1492
1493```bash
1494git 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
1495git commit -m "web: snippet pages
1496
1497The owner's list, one snippet with highlighted files, and a raw route
1498under /{owner}/-/snippets. The owner page links when there is
1499something to list.
1500
1501Ref #195"
1502```
1503
1504---
1505
1506### Task 4: Web writes
1507
1508**Files:**
1509- Modify: `internal/httpd/snippets.go` (append the write handlers)
1510- Modify: `internal/httpd/routes.go` (the account-mode block, beside the `/bookmarks` routes)
1511- Modify: `internal/web/templates/snippet.html` (the `{{if .CanWrite}}` forms)
1512- Create: `internal/web/templates/snippetnew.html`
1513- Test: `e2e/snippetweb_test.go` (extend)
1514
1515**Interfaces:**
1516- Consumes `snippetScope`, `control.SnippetOut`, `s.dispatchIntoStdin`, `s.runControlCode`, `s.runControlStdinCode`, `s.done`, `s.setFlash`, `statusForExit`.
1517- Produces the five POST handlers and the GET form named in the constraints.
1518
1519- [ ] **Step 1: Extend the e2e test**
1520
1521In `e2e/snippetweb_test.go`, replace the two placeholder lines (`_ = url.Values{}` and `_ = bobKey`) with:
1522
1523```go
1524 // The create form makes a snippet through snippet create.
1525 status, body = browserPost(t, alice, inst.base()+"/alice/-/snippets/new", url.Values{
1526 "name": {"notes.md"}, "description": {"from the browser"}, "visibility": {"public"}, "content": {"# notes\n"}})
1527 if status != 200 || !strings.Contains(body, "from the browser") || !strings.Contains(body, "notes.md") {
1528 t.Fatalf("create form: %d\n%s", status, body)
1529 }
1530 var listed struct {
1531 Data []struct {
1532 ID string `json:"id"`
1533 Description string `json:"description"`
1534 } `json:"data"`
1535 }
1536 json.Unmarshal([]byte(must(aliceKey, "", "snippet", "list", "--json")), &listed)
1537 created := ""
1538 for _, sn := range listed.Data {
1539 if sn.Description == "from the browser" {
1540 created = sn.ID
1541 }
1542 }
1543 if created == "" {
1544 t.Fatalf("created from the web, not listed: %+v", listed.Data)
1545 }
1546 if status, _ := browserGet(t, alice, inst.base()+"/bob/-/snippets/new"); status != 404 {
1547 t.Fatalf("new form under another owner: %d", status)
1548 }
1549
1550 // The file form replaces a file and adds one; remove drops it.
1551 page := inst.base() + "/alice/-/snippets/" + created
1552 if status, _ := browserPost(t, alice, page+"/file", url.Values{"name": {"notes.md"}, "content": {"# changed\n"}}); status != 200 {
1553 t.Fatal("file replace failed")
1554 }
1555 if got := must(aliceKey, "", "snippet", "file", "get", created, "notes.md"); got != "# changed\n" {
1556 t.Fatalf("after web replace: %q", got)
1557 }
1558 if status, _ := browserPost(t, alice, page+"/file", url.Values{"name": {"b.txt"}, "content": {"b\n"}}); status != 200 {
1559 t.Fatal("file add failed")
1560 }
1561 if status, _ := browserPost(t, alice, page+"/file/remove", url.Values{"name": {"b.txt"}}); status != 200 {
1562 t.Fatal("file remove failed")
1563 }
1564 if _, _, code := inst.ssh(t, aliceKey, "", "snippet", "file", "get", created, "b.txt"); code != 3 {
1565 t.Fatalf("b.txt after web remove: exit %d", code)
1566 }
1567 // A refusal comes back on the page as a message, not a bare error.
1568 _, body = browserPost(t, alice, page+"/file/remove", url.Values{"name": {"notes.md"}})
1569 if !strings.Contains(body, `class="error"`) || !strings.Contains(body, "at least one file") {
1570 t.Fatalf("last-file refusal on the page:\n%s", body)
1571 }
1572
1573 // Edit changes visibility; delete removes.
1574 if status, _ := browserPost(t, alice, page+"/edit", url.Values{"description": {"renamed"}, "visibility": {"private"}}); status != 200 {
1575 t.Fatal("edit failed")
1576 }
1577 if status, _ := inst.get(t, "/alice/-/snippets/"+created); status != 404 {
1578 t.Fatalf("private after web edit, anonymous: %d", status)
1579 }
1580 // bob cannot write alice's snippet from the browser either.
1581 bob := inst.login(t, bobKey)
1582 if status, _ := browserPost(t, bob, inst.base()+"/alice/-/snippets/"+public+"/edit", url.Values{"description": {"x"}, "visibility": {"public"}}); status != 403 {
1583 t.Fatalf("bob editing alice's snippet: %d", status)
1584 }
1585 if status, _ := browserPost(t, alice, page+"/delete", nil); status != 200 {
1586 t.Fatal("delete failed")
1587 }
1588 if _, _, code := inst.ssh(t, aliceKey, "", "snippet", "show", created); code != 3 {
1589 t.Fatalf("after web delete: exit %d", code)
1590 }
1591```
1592
1593`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.
1594
1595- [ ] **Step 2: Run it to see it fail**
1596
1597Run: `go test ./e2e -run 'TestSnippetsWeb$'`
1598Expected: FAIL at "create form" with 404 or 405.
1599
1600- [ ] **Step 3: Routes**
1601
1602In `internal/httpd/routes.go`, inside the `web.mode == "accounts"` block, after the `/bookmarks` GET route add:
1603
1604```go
1605 Route{Method: "GET", Pattern: "/{owner}/-/snippets/new", Handler: s.requireUser(s.snippetNewForm)},
1606 Route{Method: "POST", Pattern: "/{owner}/-/snippets/new", Mutating: true,
1607 Handler: s.checkOrigin(s.requireUser(s.snippetNewSubmit))},
1608 Route{Method: "POST", Pattern: "/{owner}/-/snippets/{id}/edit", Mutating: true,
1609 Handler: s.checkOrigin(s.requireUser(s.snippetEditSubmit))},
1610 Route{Method: "POST", Pattern: "/{owner}/-/snippets/{id}/delete", Mutating: true,
1611 Handler: s.checkOrigin(s.requireUser(s.snippetDeleteSubmit))},
1612 Route{Method: "POST", Pattern: "/{owner}/-/snippets/{id}/file", Mutating: true,
1613 Handler: s.checkOrigin(s.requireUser(s.snippetFileSubmit))},
1614 Route{Method: "POST", Pattern: "/{owner}/-/snippets/{id}/file/remove", Mutating: true,
1615 Handler: s.checkOrigin(s.requireUser(s.snippetFileRemoveSubmit))},
1616```
1617
1618`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.
1619
1620- [ ] **Step 4: Handlers**
1621
1622Append to `internal/httpd/snippets.go` (add `"strings"`, `"gitbay.org/gitbay/internal/control"` and `"gitbay.org/gitbay/internal/protocol"` to its imports):
1623
1624```go
1625// snippetNewForm is the owner's own page only: the URL names the owner
1626// and a snippet cannot be created for someone else.
1627func (s *Server) snippetNewForm(w http.ResponseWriter, r *http.Request, u store.User) {
1628 if r.PathValue("owner") != u.Username {
1629 s.notFound(w, r)
1630 return
1631 }
1632 s.render(w, "snippetnew.html", struct {
1633 basePage
1634 Owner string
1635 }{s.baseFor(u), u.Username})
1636}
1637
1638func (s *Server) snippetNewSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
1639 if r.PathValue("owner") != u.Username {
1640 s.notFound(w, r)
1641 return
1642 }
1643 argv := []string{"snippet", "create", strings.TrimSpace(r.FormValue("name")),
1644 "--description", strings.TrimSpace(r.FormValue("description")),
1645 "--visibility", r.FormValue("visibility")}
1646 var out control.SnippetOut
1647 code, msg := s.dispatchIntoStdin(u, argv, r.FormValue("content"), &out)
1648 if code != protocol.ExitOK {
1649 http.Error(w, msg, statusForExit(code))
1650 return
1651 }
1652 http.Redirect(w, r, "/"+u.Username+"/-/snippets/"+out.ID, http.StatusSeeOther)
1653}
1654
1655// snippetAction runs a write on the snippet in the URL and returns to
1656// its page with the message, or to the list after a delete. A snippet
1657// the viewer may not read is the 404 page, as on every read.
1658func (s *Server) snippetAction(w http.ResponseWriter, r *http.Request, u store.User, argv []string, stdin string, dest string) {
1659 sn, _, ok := s.snippetScope(w, r)
1660 if !ok {
1661 return
1662 }
1663 if dest == "" {
1664 dest = "/" + sn.OwnerName + "/-/snippets/" + sn.PublicID
1665 }
1666 back := func(w http.ResponseWriter, r *http.Request, msg string) {
1667 s.setFlash(w, msg)
1668 http.Redirect(w, r, dest, http.StatusSeeOther)
1669 }
1670 var msg string
1671 var code int
1672 if stdin == "" {
1673 _, msg, code = s.runControlCode(u, argv)
1674 } else {
1675 msg, code = s.runControlStdinCode(u, argv, stdin)
1676 }
1677 if code == protocol.ExitDenied {
1678 http.Error(w, msg, http.StatusForbidden)
1679 return
1680 }
1681 s.done(w, r, code, msg, back)
1682}
1683
1684func (s *Server) snippetEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
1685 s.snippetAction(w, r, u, []string{"snippet", "edit", r.PathValue("id"),
1686 "--description", strings.TrimSpace(r.FormValue("description")),
1687 "--visibility", r.FormValue("visibility")}, "", "")
1688}
1689
1690func (s *Server) snippetDeleteSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
1691 s.snippetAction(w, r, u, []string{"snippet", "delete", r.PathValue("id")}, "",
1692 "/"+r.PathValue("owner")+"/-/snippets")
1693}
1694
1695// An empty textarea reaches the command as empty stdin, which it refuses;
1696// the message lands on the page like any other.
1697func (s *Server) snippetFileSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
1698 s.snippetAction(w, r, u, []string{"snippet", "file", "set", r.PathValue("id"), strings.TrimSpace(r.FormValue("name"))},
1699 r.FormValue("content"), "")
1700}
1701
1702func (s *Server) snippetFileRemoveSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
1703 s.snippetAction(w, r, u, []string{"snippet", "file", "remove", r.PathValue("id"), strings.TrimSpace(r.FormValue("name"))}, "", "")
1704}
1705```
1706
1707The 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.
1708
1709- [ ] **Step 5: Templates**
1710
1711Create `internal/web/templates/snippetnew.html`:
1712
1713```html
1714{{define "title"}}new snippet · {{.Owner}}{{end}}
1715{{define "content"}}
1716<h1>New snippet</h1>
1717<form method="post" action="/{{.Owner}}/-/snippets/new" class="commentform">
1718<p><input type="text" name="name" aria-label="File name" placeholder="filename" required></p>
1719<p><input type="text" name="description" aria-label="Description" placeholder="description"></p>
1720<p><select name="visibility" aria-label="Visibility"><option value="unlisted">unlisted</option><option value="public">public</option><option value="private">private</option></select></p>
1721<p><textarea name="content" aria-label="Content" rows="16" required></textarea></p>
1722<p><button type="submit">Create snippet</button></p>
1723</form>
1724{{end}}
1725```
1726
1727In `internal/web/templates/snippet.html`, inside the `{{range .Files}}` section after `<div class="code">{{.HTML}}</div>`, add:
1728
1729```html
1730{{if $.CanWrite}}<details class="editbox"><summary>edit {{.Name}}</summary>
1731<form method="post" action="/{{$.Owner}}/-/snippets/{{$.Snippet.PublicID}}/file" class="commentform">
1732<input type="hidden" name="name" value="{{.Name}}">
1733<p><textarea name="content" aria-label="Content of {{.Name}}" rows="12">{{.Content}}</textarea></p>
1734<p><button type="submit">Save</button></p>
1735</form>
1736<form method="post" action="/{{$.Owner}}/-/snippets/{{$.Snippet.PublicID}}/file/remove">
1737<input type="hidden" name="name" value="{{.Name}}">
1738<p><button type="submit">Remove {{.Name}}</button></p>
1739</form>
1740</details>{{end}}
1741```
1742
1743and after the `{{end}}` that closes the range, before the final `{{end}}`:
1744
1745```html
1746{{if .CanWrite}}
1747<details class="editbox"><summary>add a file</summary>
1748<form method="post" action="/{{.Owner}}/-/snippets/{{.Snippet.PublicID}}/file" class="commentform">
1749<p><input type="text" name="name" aria-label="File name" placeholder="filename" required></p>
1750<p><textarea name="content" aria-label="Content" rows="12" required></textarea></p>
1751<p><button type="submit">Add file</button></p>
1752</form></details>
1753<details class="editbox"><summary>settings</summary>
1754<form method="post" action="/{{.Owner}}/-/snippets/{{.Snippet.PublicID}}/edit">
1755<p><input type="text" name="description" aria-label="Description" value="{{.Snippet.Description}}" placeholder="description"></p>
1756<p><select name="visibility" aria-label="Visibility">
1757<option value="public"{{if eq .Snippet.Visibility "public"}} selected{{end}}>public</option>
1758<option value="unlisted"{{if eq .Snippet.Visibility "unlisted"}} selected{{end}}>unlisted</option>
1759<option value="private"{{if eq .Snippet.Visibility "private"}} selected{{end}}>private</option>
1760</select></p>
1761<p><button type="submit">Save</button></p>
1762</form>
1763<form method="post" action="/{{.Owner}}/-/snippets/{{.Snippet.PublicID}}/delete">
1764<p><button type="submit">Delete snippet</button></p>
1765</form>
1766</details>
1767{{end}}
1768```
1769
1770If `.editbox` or `.commentform` render poorly beside `.code`, add a `.snippetfile { margin-bottom: 1.5rem }` rule to `internal/web/static/style.css`; nothing more.
1771
1772- [ ] **Step 6: Build and run the tests**
1773
1774Run: `go build ./... && go vet ./... && go test ./internal/httpd && go test ./e2e -run 'TestSnippetsWeb$'`
1775Expected: 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.
1776
1777- [ ] **Step 7: Commit**
1778
1779```bash
1780git add internal/httpd/snippets.go internal/httpd/routes.go internal/web/templates/snippet.html internal/web/templates/snippetnew.html e2e/snippetweb_test.go
1781git commit -m "web: create, edit and delete snippets
1782
1783Every form dispatches the snippet command the CLI runs.
1784
1785Ref #195"
1786```
1787
1788---
1789
1790### Task 5: Documentation and changelog
1791
1792**Files:**
1793- Modify: `.gitbay/wiki/Users.org` (a `* Snippets` section after `* Pages`, before `* Browser sessions`)
1794- Modify: `.gitbay/wiki/Parity.org` (rows after `release asset remove`)
1795- Modify: `.gitbay/wiki/Admin.org:116` (the `[limits]` list)
1796- Modify: `CHANGELOG.org` (a new top entry)
1797
1798- [ ] **Step 1: Users.org**
1799
1800Insert before `* Browser sessions`:
1801
1802```org
1803* Snippets
1804
1805A snippet is one or more named text files you own outside any
1806repository, for a log or a fragment shared by URL. Create one from a
1807file on stdin; the reply is the id and the URL:
1808
1809#+begin_src sh
1810gitbay snippet create build.log --description "failing build" < build.log
1811gitbay snippet file set <id> notes.txt < notes.txt # add or replace a file
1812gitbay snippet file get <id> build.log > build.log
1813gitbay snippet edit <id> --visibility public
1814gitbay snippet list # yours
1815gitbay snippet list <owner> # their public ones
1816gitbay snippet delete <id>
1817#+end_src
1818
1819Visibility is =public= (listed on your page), =unlisted= (anyone with
1820the URL, listed nowhere; the default) or =private= (you alone; not
1821found to everyone else). Files are text, valid UTF-8, each under the
1822instance's =max_snippet_bytes=, at most 64 per snippet. A snippet keeps
1823at least one file. There is no history: setting a file replaces it.
1824
1825On the web, =/<you>/-/snippets= lists yours, each snippet page renders
1826its files with a raw link per file, and the same page creates, edits
1827and deletes through the commands above.
1828```
1829
1830- [ ] **Step 2: Parity.org**
1831
1832After the `release asset remove` row add:
1833
1834```org
1835| snippet create, edit, delete | yes | yes | no |
1836| snippet show, list | yes | yes | no |
1837| snippet file set, get, remove | yes | yes | no |
1838```
1839
1840Match the table's column alignment by hand; org tables tolerate ragged cells but the page is read raw too.
1841
1842- [ ] **Step 3: Admin.org**
1843
1844After the `max_asset_bytes` line in `** [limits]` add:
1845
1846```org
1847- =max_snippet_bytes= (1MB) — cap per snippet file.
1848```
1849
1850- [ ] **Step 4: CHANGELOG.org**
1851
1852Before `* v1.19.0 — 2026-09-11` add:
1853
1854```org
1855* v1.20.0 — unreleased
1856
1857Snippets (#195): named text files a user owns outside any repository,
1858shared by URL and edited in place.
1859
1860- Migration 0053: =snippets= and =snippet_files=.
1861- =snippet create|show|list|edit|delete= and =snippet file
1862 set|get|remove=. Files are UTF-8 under =limits.max_snippet_bytes=
1863 (1MB), at most 64 per snippet; a snippet keeps at least one.
1864 Visibility =public=, =unlisted= (default) or =private=; a private
1865 snippet is not found to everyone but its owner and admins.
1866- Web: =/<owner>/-/snippets= lists, each snippet page renders its
1867 files with a raw route per file, and the owner creates, edits and
1868 deletes from the page through the same commands. The owner page
1869 links to the list.
1870```
1871
1872- [ ] **Step 5: Commit**
1873
1874```bash
1875git add .gitbay/wiki/Users.org .gitbay/wiki/Parity.org .gitbay/wiki/Admin.org CHANGELOG.org
1876git commit -m "wiki, CHANGELOG: snippets
1877
1878Closes #195"
1879```
1880
1881---
1882
1883### Task 6: Merge request
1884
1885- [ ] **Step 1: Push and open the MR**
1886
1887```bash
1888git push -u origin snippets
1889gitbay mr create --source snippets --target main --title "Snippets (#195)" --file - <<'EOF'
1890Named text files a user owns outside any repository, shared by URL and
1891edited in place. Spec: docs/specs/2026-09-11-snippets-design.md.
1892
1893Migration 0053. Commands snippet create|show|list|edit|delete and
1894snippet file set|get|remove; web under /{owner}/-/snippets with forms
1895dispatching the same commands. New limit max_snippet_bytes (1MB).
1896
1897Closes #195
1898EOF
1899```
1900
1901- [ ] **Step 2: Wait for CI, then merge**
1902
1903Check `gitbay build list --json` until the build for the branch head succeeds; read `gitbay build log <n>` on failure and fix on the branch. Then:
1904
1905```bash
1906gitbay mr merge <n> --strategy ff
1907git push origin --delete snippets
1908```
1909
1910and remove the worktree and branch locally after the merge lands.