docs/plans/2026-09-11-snippets.md

v1.30.0
gitbay/docs/plans/2026-09-11-snippets.md rendered · source · history · blame · raw

1910 lines · 71051 bytes

   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 0054) 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 0054 and the store
  37
  38**Files:**
  39- Create: `internal/store/migrations/0054_snippets.up.sql`
  40- Create: `internal/store/migrations/0054_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/0054_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/0054_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/0054_snippets.up.sql internal/store/migrations/0054_snippets.down.sql internal/store/snippets.go internal/store/snippets_test.go
 467git commit -m "store: snippets and snippet_files (migration 0054)
 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 0054: =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 0054. 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.